mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-02 17:40:02 +02:00
style(cso): format lib/cso TypeScript with pinned Prettier
Mechanical reformat only. Minified transpile output is byte-identical for 21 of 22 files; witness.ts differs only in three regex flag orders (/mi -> /im), which JavaScript canonicalizes. Source-text assertions over lib/cso now compare whitespace-insensitively with the same tokens.
This commit is contained in:
1 parent
dcaea52800
commit
d93d61f7ba
27 files changed
+17942
-4164
No files matched your search
+581
-126
@@ -8,8 +8,9 @@ import { posix } from 'node:path';
|
||||
import { redactFindingSpans } from '../redact-engine';
|
||||
|
||||
export const SCANNER_IDS = ['gitleaks', 'osv', 'semgrep', 'zizmor', 'trivy', 'schemathesis'] as const;
|
||||
export type ScannerId = typeof SCANNER_IDS[number];
|
||||
export type ScannerFormat = 'gitleaks-json' | 'osv-json' | 'semgrep-json' | 'sarif' | 'trivy-json' | 'schemathesis-json';
|
||||
export type ScannerId = (typeof SCANNER_IDS)[number];
|
||||
export type ScannerFormat =
|
||||
'gitleaks-json' | 'osv-json' | 'semgrep-json' | 'sarif' | 'trivy-json' | 'schemathesis-json';
|
||||
export const MAX_SCANNER_OUTPUT_BYTES = 1_048_576;
|
||||
const MAX_CANDIDATES = 5_000;
|
||||
|
||||
@@ -63,7 +64,13 @@ export interface ScannerCandidate {
|
||||
reportedSeverity: 'critical' | 'high' | 'medium' | 'low' | 'info' | 'unknown';
|
||||
location?: { path: string; line?: number; column?: number };
|
||||
advisoryIds: string[];
|
||||
dependency?: { name: string; version?: string; ecosystem?: string; reachability: 'unknown'; exposure: 'unknown' };
|
||||
dependency?: {
|
||||
name: string;
|
||||
version?: string;
|
||||
ecosystem?: string;
|
||||
reachability: 'unknown';
|
||||
exposure: 'unknown';
|
||||
};
|
||||
operation?: string;
|
||||
suppressed: boolean;
|
||||
evidence: 'scanner-candidate';
|
||||
@@ -71,10 +78,25 @@ export interface ScannerCandidate {
|
||||
}
|
||||
|
||||
export interface ScannerGap {
|
||||
code: 'UNAVAILABLE' | 'PREREQUISITE' | 'TIMEOUT' | 'OUTPUT_LIMIT' | 'INVALID_OUTPUT' | 'TOOL_FAILED' |
|
||||
'REDACTION_FAILED' | 'ISOLATION_FAILED' | 'PERSISTENCE_FAILED' | 'SNAPSHOT_RACE' | 'CANCELLED' |
|
||||
'INSUFFICIENT_CAPACITY' | 'UNSAFE_PATH' | 'MISSING_INPUT' | 'INCOMPATIBLE_INPUT' |
|
||||
'UNSAFE_LOCATION' | 'SKIPPED_INPUT' | 'UNKNOWN_FRESHNESS';
|
||||
code:
|
||||
| 'UNAVAILABLE'
|
||||
| 'PREREQUISITE'
|
||||
| 'TIMEOUT'
|
||||
| 'OUTPUT_LIMIT'
|
||||
| 'INVALID_OUTPUT'
|
||||
| 'TOOL_FAILED'
|
||||
| 'REDACTION_FAILED'
|
||||
| 'ISOLATION_FAILED'
|
||||
| 'PERSISTENCE_FAILED'
|
||||
| 'SNAPSHOT_RACE'
|
||||
| 'CANCELLED'
|
||||
| 'INSUFFICIENT_CAPACITY'
|
||||
| 'UNSAFE_PATH'
|
||||
| 'MISSING_INPUT'
|
||||
| 'INCOMPATIBLE_INPUT'
|
||||
| 'UNSAFE_LOCATION'
|
||||
| 'SKIPPED_INPUT'
|
||||
| 'UNKNOWN_FRESHNESS';
|
||||
message: string;
|
||||
}
|
||||
|
||||
@@ -109,34 +131,64 @@ export interface ScannerExecution {
|
||||
|
||||
const SOURCES: Record<ScannerId, string[]> = {
|
||||
gitleaks: ['https://github.com/gitleaks/gitleaks/blob/master/README.md'],
|
||||
osv: ['https://google.github.io/osv-scanner/usage/scan-source/', 'https://google.github.io/osv-scanner/usage/offline-mode/'],
|
||||
osv: [
|
||||
'https://google.github.io/osv-scanner/usage/scan-source/',
|
||||
'https://google.github.io/osv-scanner/usage/offline-mode/',
|
||||
],
|
||||
semgrep: ['https://docs.semgrep.dev/cli-reference'],
|
||||
zizmor: ['https://docs.zizmor.sh/usage/', 'https://docs.zizmor.sh/quickstart/'],
|
||||
trivy: ['https://trivy.dev/docs/dev/docs/advanced/telemetry/', 'https://trivy.dev/docs/latest/guide/advanced/air-gap/'],
|
||||
schemathesis: ['https://schemathesis.readthedocs.io/en/stable/reference/cli/', 'https://github.com/schemathesis/schemathesis/blob/master/src/schemathesis/cli/json_report.py'],
|
||||
trivy: [
|
||||
'https://trivy.dev/docs/dev/docs/advanced/telemetry/',
|
||||
'https://trivy.dev/docs/latest/guide/advanced/air-gap/',
|
||||
],
|
||||
schemathesis: [
|
||||
'https://schemathesis.readthedocs.io/en/stable/reference/cli/',
|
||||
'https://github.com/schemathesis/schemathesis/blob/master/src/schemathesis/cli/json_report.py',
|
||||
],
|
||||
};
|
||||
|
||||
function absolutePath(value: string, name: string): string {
|
||||
if (value === '/' || !value.startsWith('/') || value.startsWith('//') || /[\x00-\x1f\\]/.test(value) || value.split('/').includes('..')) {
|
||||
if (
|
||||
value === '/' ||
|
||||
!value.startsWith('/') ||
|
||||
value.startsWith('//') ||
|
||||
/[\x00-\x1f\\]/.test(value) ||
|
||||
value.split('/').includes('..')
|
||||
) {
|
||||
throw new Error(`${name} must be an absolute sandbox path without traversal`);
|
||||
}
|
||||
return posix.normalize(value);
|
||||
}
|
||||
|
||||
function positiveInteger(value: number, max: number, name: string): number {
|
||||
if (!Number.isSafeInteger(value) || value < 1 || value > max) throw new Error(`${name} must be between 1 and ${max}`);
|
||||
if (!Number.isSafeInteger(value) || value < 1 || value > max)
|
||||
throw new Error(`${name} must be between 1 and ${max}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Numeric loopback only: no DNS, URL credentials, redirected targets, or remote schemas. */
|
||||
export function validateScannerBaseUrl(raw: string): string {
|
||||
let url: URL;
|
||||
try { url = new URL(raw); } catch { throw new Error('Schemathesis requires a numeric loopback HTTP URL'); }
|
||||
if (!['http:', 'https:'].includes(url.protocol) || !['127.0.0.1', '[::1]'].includes(url.hostname) || url.username || url.password || url.hash || url.search) {
|
||||
throw new Error('Schemathesis requires a numeric loopback HTTP URL without credentials, query, or fragment');
|
||||
try {
|
||||
url = new URL(raw);
|
||||
} catch {
|
||||
throw new Error('Schemathesis requires a numeric loopback HTTP URL');
|
||||
}
|
||||
if (
|
||||
!['http:', 'https:'].includes(url.protocol) ||
|
||||
!['127.0.0.1', '[::1]'].includes(url.hostname) ||
|
||||
url.username ||
|
||||
url.password ||
|
||||
url.hash ||
|
||||
url.search
|
||||
) {
|
||||
throw new Error(
|
||||
'Schemathesis requires a numeric loopback HTTP URL without credentials, query, or fragment',
|
||||
);
|
||||
}
|
||||
// URL canonicalization accepts integer, hex, and shorthand IPv4. Reject these spellings.
|
||||
if (!/^https?:\/\/(127\.0\.0\.1|\[::1\])(?::\d+)?(?:\/|$)/.test(raw)) throw new Error('Schemathesis requires canonical numeric loopback');
|
||||
if (!/^https?:\/\/(127\.0\.0\.1|\[::1\])(?::\d+)?(?:\/|$)/.test(raw))
|
||||
throw new Error('Schemathesis requires canonical numeric loopback');
|
||||
return url.href;
|
||||
}
|
||||
|
||||
@@ -148,99 +200,288 @@ export function validateScannerBaseUrl(raw: string): string {
|
||||
export function scannerPlans(opts: ScannerOptions): ScannerPlan[] {
|
||||
const root = absolutePath(opts.snapshotRoot, 'snapshotRoot');
|
||||
const policy = absolutePath(opts.policyRoot ?? '/policy', 'policyRoot');
|
||||
if (policy === root || policy.startsWith(`${root}/`) || root.startsWith(`${policy}/`)) throw new Error('policyRoot must be separate from source');
|
||||
if (policy === root || policy.startsWith(`${root}/`) || root.startsWith(`${policy}/`))
|
||||
throw new Error('policyRoot must be separate from source');
|
||||
const cache = opts.advisoryCache ? absolutePath(opts.advisoryCache, 'advisoryCache') : undefined;
|
||||
if (cache && (cache === root || cache.startsWith(`${root}/`))) throw new Error('advisoryCache must be separate from source');
|
||||
if (cache && (cache === root || cache.startsWith(`${root}/`)))
|
||||
throw new Error('advisoryCache must be separate from source');
|
||||
const timeout = positiveInteger(opts.deadlineSeconds ?? 120, 300, 'deadlineSeconds');
|
||||
const selected = opts.selected ?? [...SCANNER_IDS];
|
||||
if (new Set(selected).size !== selected.length || selected.some(id => !SCANNER_IDS.includes(id))) throw new Error('Invalid or duplicate scanner selection');
|
||||
return selected.map(id => {
|
||||
if (new Set(selected).size !== selected.length || selected.some((id) => !SCANNER_IDS.includes(id)))
|
||||
throw new Error('Invalid or duplicate scanner selection');
|
||||
return selected.map((id) => {
|
||||
const plan: ScannerPlan = {
|
||||
id, executableName: id === 'osv' ? 'osv-scanner' : id, args: [], versionArgs: ['--version'], requiredFeatures: [],
|
||||
format: 'sarif', execution: 'sandbox', network: 'none', cwd: '/work', sourceRoot: root,
|
||||
env: { HOME: '/work/home', TMPDIR: '/tmp', LANG: 'C.UTF-8', NO_COLOR: '1' }, trustedFiles: [], prerequisites: [],
|
||||
timeoutSeconds: timeout, maxOutputBytes: MAX_SCANNER_OUTPUT_BYTES,
|
||||
coverage: { domain: id, scope: [root], exclusions: ['Snapshot transformations apply; inspect the snapshot manifest.'] },
|
||||
provenanceSources: SOURCES[id], documentationInspectedAt: '2026-09-09',
|
||||
id,
|
||||
executableName: id === 'osv' ? 'osv-scanner' : id,
|
||||
args: [],
|
||||
versionArgs: ['--version'],
|
||||
requiredFeatures: [],
|
||||
format: 'sarif',
|
||||
execution: 'sandbox',
|
||||
network: 'none',
|
||||
cwd: '/work',
|
||||
sourceRoot: root,
|
||||
env: { HOME: '/work/home', TMPDIR: '/tmp', LANG: 'C.UTF-8', NO_COLOR: '1' },
|
||||
trustedFiles: [],
|
||||
prerequisites: [],
|
||||
timeoutSeconds: timeout,
|
||||
maxOutputBytes: MAX_SCANNER_OUTPUT_BYTES,
|
||||
coverage: {
|
||||
domain: id,
|
||||
scope: [root],
|
||||
exclusions: ['Snapshot transformations apply; inspect the snapshot manifest.'],
|
||||
},
|
||||
provenanceSources: SOURCES[id],
|
||||
documentationInspectedAt: '2026-09-09',
|
||||
};
|
||||
if (opts.tools?.[id]?.available === false) plan.prerequisites.push(`Install a reviewed ${plan.executableName} executable in the scanner image.`);
|
||||
if (opts.tools?.[id]?.available === false)
|
||||
plan.prerequisites.push(`Install a reviewed ${plan.executableName} executable in the scanner image.`);
|
||||
switch (id) {
|
||||
case 'gitleaks': {
|
||||
const target = opts.gitHistory ? absolutePath(opts.gitHistory, 'gitHistory') : root;
|
||||
plan.format = 'gitleaks-json';
|
||||
plan.coverage.domain = 'secrets';
|
||||
plan.coverage.scope = [target];
|
||||
plan.trustedFiles.push({ path: `${policy}/gitleaks.toml`, content: '[extend]\nuseDefault = true\n' }, { path: `${policy}/gitleaksignore`, content: '' });
|
||||
plan.args = [opts.gitHistory ? 'git' : 'dir', '--redact=100', '--no-banner', '--no-color', '--ignore-gitleaks-allow', '--gitleaks-ignore-path', `${policy}/gitleaksignore`, '--config', `${policy}/gitleaks.toml`, '--report-format=json', '--report-path=-', '--exit-code=10', '--timeout', String(timeout), target];
|
||||
if (opts.gitHistory) plan.prerequisites.push('History input must be a sanitized Git object store with trusted config and no hooks, filters, alternates, or external helpers.');
|
||||
plan.trustedFiles.push(
|
||||
{ path: `${policy}/gitleaks.toml`, content: '[extend]\nuseDefault = true\n' },
|
||||
{ path: `${policy}/gitleaksignore`, content: '' },
|
||||
);
|
||||
plan.args = [
|
||||
opts.gitHistory ? 'git' : 'dir',
|
||||
'--redact=100',
|
||||
'--no-banner',
|
||||
'--no-color',
|
||||
'--ignore-gitleaks-allow',
|
||||
'--gitleaks-ignore-path',
|
||||
`${policy}/gitleaksignore`,
|
||||
'--config',
|
||||
`${policy}/gitleaks.toml`,
|
||||
'--report-format=json',
|
||||
'--report-path=-',
|
||||
'--exit-code=10',
|
||||
'--timeout',
|
||||
String(timeout),
|
||||
target,
|
||||
];
|
||||
if (opts.gitHistory)
|
||||
plan.prerequisites.push(
|
||||
'History input must be a sanitized Git object store with trusted config and no hooks, filters, alternates, or external helpers.',
|
||||
);
|
||||
else plan.coverage.exclusions.push('Historical revisions are not scanned by this directory pass.');
|
||||
plan.requiredFeatures = ['dir', '--redact', '--ignore-gitleaks-allow'];
|
||||
break;
|
||||
}
|
||||
case 'osv':
|
||||
plan.format = 'osv-json'; plan.coverage.domain = 'dependencies';
|
||||
plan.format = 'osv-json';
|
||||
plan.coverage.domain = 'dependencies';
|
||||
plan.trustedFiles.push({ path: `${policy}/osv-scanner.toml`, content: '' });
|
||||
plan.args = ['scan', 'source', '--format=json', '--offline', '--no-call-analysis=all', '--config', `${policy}/osv-scanner.toml`, '--recursive', root];
|
||||
plan.args = [
|
||||
'scan',
|
||||
'source',
|
||||
'--format=json',
|
||||
'--offline',
|
||||
'--no-call-analysis=all',
|
||||
'--config',
|
||||
`${policy}/osv-scanner.toml`,
|
||||
'--recursive',
|
||||
root,
|
||||
];
|
||||
plan.requiredFeatures = ['scan source', '--offline', '--no-call-analysis'];
|
||||
if (cache) plan.env.OSV_SCANNER_LOCAL_DB_CACHE_DIRECTORY = cache;
|
||||
else plan.prerequisites.push('Provide verified offline OSV databases for every assessed ecosystem.');
|
||||
plan.coverage.exclusions.push('Call analysis is disabled; dependency reachability remains unknown until independently investigated.');
|
||||
plan.coverage.exclusions.push(
|
||||
'Call analysis is disabled; dependency reachability remains unknown until independently investigated.',
|
||||
);
|
||||
break;
|
||||
case 'semgrep': {
|
||||
plan.format = 'semgrep-json'; plan.coverage.domain = 'code';
|
||||
const rules = opts.semgrepRules ? absolutePath(opts.semgrepRules, 'semgrepRules') : `${policy}/semgrep.yml`;
|
||||
if (!rules.startsWith(`${policy}/`)) throw new Error('Semgrep rules must be below the trusted policyRoot');
|
||||
if (!opts.semgrepRules) plan.prerequisites.push('Provide a reviewed, pinned local Semgrep ruleset; registry aliases and repo rules are not accepted.');
|
||||
plan.args = ['scan', '--json', '--config', rules, '--metrics=off', '--disable-version-check', '--disable-nosem', '--no-git-ignore', '--no-secrets-validation', '--oss-only', '--no-autofix', '--timeout=10', '--timeout-threshold=3', '--jobs=1', root];
|
||||
plan.env.SEMGREP_SEND_METRICS = 'off'; plan.env.SEMGREP_ENABLE_VERSION_CHECK = '0'; plan.env.SEMGREP_APP_TOKEN = '';
|
||||
plan.requiredFeatures = ['scan', '--metrics', '--disable-version-check', '--no-secrets-validation', '--oss-only'];
|
||||
plan.coverage.exclusions.push('Semgrep language support, built-in file selection, and .semgrepignore rules can exclude inputs; independently inspect these exclusions.');
|
||||
plan.format = 'semgrep-json';
|
||||
plan.coverage.domain = 'code';
|
||||
const rules = opts.semgrepRules
|
||||
? absolutePath(opts.semgrepRules, 'semgrepRules')
|
||||
: `${policy}/semgrep.yml`;
|
||||
if (!rules.startsWith(`${policy}/`))
|
||||
throw new Error('Semgrep rules must be below the trusted policyRoot');
|
||||
if (!opts.semgrepRules)
|
||||
plan.prerequisites.push(
|
||||
'Provide a reviewed, pinned local Semgrep ruleset; registry aliases and repo rules are not accepted.',
|
||||
);
|
||||
plan.args = [
|
||||
'scan',
|
||||
'--json',
|
||||
'--config',
|
||||
rules,
|
||||
'--metrics=off',
|
||||
'--disable-version-check',
|
||||
'--disable-nosem',
|
||||
'--no-git-ignore',
|
||||
'--no-secrets-validation',
|
||||
'--oss-only',
|
||||
'--no-autofix',
|
||||
'--timeout=10',
|
||||
'--timeout-threshold=3',
|
||||
'--jobs=1',
|
||||
root,
|
||||
];
|
||||
plan.env.SEMGREP_SEND_METRICS = 'off';
|
||||
plan.env.SEMGREP_ENABLE_VERSION_CHECK = '0';
|
||||
plan.env.SEMGREP_APP_TOKEN = '';
|
||||
plan.requiredFeatures = [
|
||||
'scan',
|
||||
'--metrics',
|
||||
'--disable-version-check',
|
||||
'--no-secrets-validation',
|
||||
'--oss-only',
|
||||
];
|
||||
plan.coverage.exclusions.push(
|
||||
'Semgrep language support, built-in file selection, and .semgrepignore rules can exclude inputs; independently inspect these exclusions.',
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'zizmor':
|
||||
plan.coverage.domain = 'github-actions';
|
||||
plan.args = ['--offline', '--no-config', '--no-ignores', '--no-exit-codes', '--no-progress', '--color=never', '--format=sarif', root];
|
||||
plan.env.ZIZMOR_OFFLINE = '1'; plan.requiredFeatures = ['--offline', '--no-config', '--no-ignores'];
|
||||
plan.coverage.exclusions.push('Online GitHub audits and remote reusable action inspection require separate assessment.');
|
||||
plan.args = [
|
||||
'--offline',
|
||||
'--no-config',
|
||||
'--no-ignores',
|
||||
'--no-exit-codes',
|
||||
'--no-progress',
|
||||
'--color=never',
|
||||
'--format=sarif',
|
||||
root,
|
||||
];
|
||||
plan.env.ZIZMOR_OFFLINE = '1';
|
||||
plan.requiredFeatures = ['--offline', '--no-config', '--no-ignores'];
|
||||
plan.coverage.exclusions.push(
|
||||
'Online GitHub audits and remote reusable action inspection require separate assessment.',
|
||||
);
|
||||
break;
|
||||
case 'trivy':
|
||||
plan.format = 'trivy-json'; plan.coverage.domain = 'dependencies-and-infrastructure';
|
||||
plan.trustedFiles.push({ path: `${policy}/trivy.yaml`, content: '{}\n' }, { path: `${policy}/trivyignore`, content: '' });
|
||||
plan.args = ['fs', '--format=json', '--config', `${policy}/trivy.yaml`, '--ignorefile', `${policy}/trivyignore`, '--scanners=vuln,misconfig,secret', '--cache-backend=memory', '--disable-telemetry', '--offline-scan', '--skip-db-update', '--skip-java-db-update', '--skip-check-update', '--skip-version-check', '--skip-vex-repo-update', '--timeout', `${timeout}s`, ...(cache ? ['--cache-dir', cache] : []), root];
|
||||
plan.format = 'trivy-json';
|
||||
plan.coverage.domain = 'dependencies-and-infrastructure';
|
||||
plan.trustedFiles.push(
|
||||
{ path: `${policy}/trivy.yaml`, content: '{}\n' },
|
||||
{ path: `${policy}/trivyignore`, content: '' },
|
||||
);
|
||||
plan.args = [
|
||||
'fs',
|
||||
'--format=json',
|
||||
'--config',
|
||||
`${policy}/trivy.yaml`,
|
||||
'--ignorefile',
|
||||
`${policy}/trivyignore`,
|
||||
'--scanners=vuln,misconfig,secret',
|
||||
'--cache-backend=memory',
|
||||
'--disable-telemetry',
|
||||
'--offline-scan',
|
||||
'--skip-db-update',
|
||||
'--skip-java-db-update',
|
||||
'--skip-check-update',
|
||||
'--skip-version-check',
|
||||
'--skip-vex-repo-update',
|
||||
'--timeout',
|
||||
`${timeout}s`,
|
||||
...(cache ? ['--cache-dir', cache] : []),
|
||||
root,
|
||||
];
|
||||
plan.env.TRIVY_DISABLE_TELEMETRY = 'true';
|
||||
plan.requiredFeatures = ['--cache-backend', '--disable-telemetry', '--offline-scan', '--skip-db-update', '--skip-java-db-update', '--skip-check-update', '--skip-version-check', '--skip-vex-repo-update'];
|
||||
if (!cache) plan.prerequisites.push('Provide verified offline Trivy vulnerability, Java, and misconfiguration databases as needed.');
|
||||
plan.requiredFeatures = [
|
||||
'--cache-backend',
|
||||
'--disable-telemetry',
|
||||
'--offline-scan',
|
||||
'--skip-db-update',
|
||||
'--skip-java-db-update',
|
||||
'--skip-check-update',
|
||||
'--skip-version-check',
|
||||
'--skip-vex-repo-update',
|
||||
];
|
||||
if (!cache)
|
||||
plan.prerequisites.push(
|
||||
'Provide verified offline Trivy vulnerability, Java, and misconfiguration databases as needed.',
|
||||
);
|
||||
break;
|
||||
case 'schemathesis': {
|
||||
plan.format = 'schemathesis-json'; plan.network = 'loopback'; plan.coverage.domain = 'api-runtime';
|
||||
plan.format = 'schemathesis-json';
|
||||
plan.network = 'loopback';
|
||||
plan.coverage.domain = 'api-runtime';
|
||||
plan.outputPath = '/work/schemathesis.json';
|
||||
// The upstream image enables a Python hook module and coverage plugin by
|
||||
// default. Qualified CSO scans use only the reviewed schema/config.
|
||||
plan.env.SCHEMATHESIS_HOOKS = ''; plan.env.SCHEMATHESIS_COVERAGE = 'false';
|
||||
plan.env.SCHEMATHESIS_HOOKS = '';
|
||||
plan.env.SCHEMATHESIS_COVERAGE = 'false';
|
||||
plan.trustedFiles.push({ path: `${policy}/schemathesis.toml`, content: '' });
|
||||
const schema = opts.schemaPath ? absolutePath(opts.schemaPath, 'schemaPath') : `${policy}/openapi.json`;
|
||||
if (!schema.startsWith(`${policy}/`)) throw new Error('Schemathesis schema must be below trusted policyRoot');
|
||||
if (!opts.schemaPath) plan.prerequisites.push('Provide a reviewed local schema with resolved local references, no remote references, and no hook imports.');
|
||||
const schema = opts.schemaPath
|
||||
? absolutePath(opts.schemaPath, 'schemaPath')
|
||||
: `${policy}/openapi.json`;
|
||||
if (!schema.startsWith(`${policy}/`))
|
||||
throw new Error('Schemathesis schema must be below trusted policyRoot');
|
||||
if (!opts.schemaPath)
|
||||
plan.prerequisites.push(
|
||||
'Provide a reviewed local schema with resolved local references, no remote references, and no hook imports.',
|
||||
);
|
||||
const base = opts.baseUrl ? validateScannerBaseUrl(opts.baseUrl) : 'http://127.0.0.1:3000/';
|
||||
if (!opts.baseUrl) plan.prerequisites.push('Start the application and a legitimate control in the admitted loopback namespace.');
|
||||
if (!opts.baseUrl)
|
||||
plan.prerequisites.push(
|
||||
'Start the application and a legitimate control in the admitted loopback namespace.',
|
||||
);
|
||||
const seed = positiveInteger(opts.seed ?? 1, 2_147_483_647, 'seed');
|
||||
const examples = positiveInteger(opts.maxExamples ?? 20, 100, 'maxExamples');
|
||||
const operations = opts.operationIds ?? [];
|
||||
if (operations.length === 0 || operations.length > 20) plan.prerequisites.push('Declare between 1 and 20 reviewed operation IDs to bound the API assessment.');
|
||||
if (operations.some(op => !op || op.length > 200 || /[\x00-\x1f]/.test(op))) throw new Error('Invalid Schemathesis operation ID');
|
||||
plan.args = ['--config-file', `${policy}/schemathesis.toml`, '--no-color', 'run', schema, '--url', base, '--workers=1', '--phases=fuzzing', '--max-examples', String(examples), '--max-failures=10', '--max-time', String(timeout), '--seed', String(seed), '--request-timeout=5', '--request-retries=0', '--max-redirects=0', '--rate-limit=10/s', '--output-sanitize=true', '--generation-database=none', '--report-json-path', plan.outputPath, ...operations.flatMap(op => ['--include-operation-id', op])];
|
||||
plan.requiredFeatures = ['--report-json-path', '--max-time', '--seed', '--max-redirects', '--include-operation-id'];
|
||||
plan.coverage.scope = operations.map(op => `operation:${op}`);
|
||||
plan.coverage.exclusions.push('Only declared operations and generated examples are exercised; API failures are candidates, not security proofs.');
|
||||
if (operations.length === 0 || operations.length > 20)
|
||||
plan.prerequisites.push(
|
||||
'Declare between 1 and 20 reviewed operation IDs to bound the API assessment.',
|
||||
);
|
||||
if (operations.some((op) => !op || op.length > 200 || /[\x00-\x1f]/.test(op)))
|
||||
throw new Error('Invalid Schemathesis operation ID');
|
||||
plan.args = [
|
||||
'--config-file',
|
||||
`${policy}/schemathesis.toml`,
|
||||
'--no-color',
|
||||
'run',
|
||||
schema,
|
||||
'--url',
|
||||
base,
|
||||
'--workers=1',
|
||||
'--phases=fuzzing',
|
||||
'--max-examples',
|
||||
String(examples),
|
||||
'--max-failures=10',
|
||||
'--max-time',
|
||||
String(timeout),
|
||||
'--seed',
|
||||
String(seed),
|
||||
'--request-timeout=5',
|
||||
'--request-retries=0',
|
||||
'--max-redirects=0',
|
||||
'--rate-limit=10/s',
|
||||
'--output-sanitize=true',
|
||||
'--generation-database=none',
|
||||
'--report-json-path',
|
||||
plan.outputPath,
|
||||
...operations.flatMap((op) => ['--include-operation-id', op]),
|
||||
];
|
||||
plan.requiredFeatures = [
|
||||
'--report-json-path',
|
||||
'--max-time',
|
||||
'--seed',
|
||||
'--max-redirects',
|
||||
'--include-operation-id',
|
||||
];
|
||||
plan.coverage.scope = operations.map((op) => `operation:${op}`);
|
||||
plan.coverage.exclusions.push(
|
||||
'Only declared operations and generated examples are exercised; API failures are candidates, not security proofs.',
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
const capabilities = opts.tools?.[id]?.capabilities;
|
||||
if (capabilities) for (const required of plan.requiredFeatures) {
|
||||
if (!capabilities.includes(required)) plan.prerequisites.push(`${plan.executableName} lacks required capability ${required}.`);
|
||||
}
|
||||
if (capabilities)
|
||||
for (const required of plan.requiredFeatures) {
|
||||
if (!capabilities.includes(required))
|
||||
plan.prerequisites.push(`${plan.executableName} lacks required capability ${required}.`);
|
||||
}
|
||||
const version = opts.tools?.[id]?.version;
|
||||
if (id === 'osv' && version && !/\b(?:v)?2\./.test(version)) plan.prerequisites.push('OSV-Scanner major version 2 is required.');
|
||||
if (id === 'osv' && version && !/\b(?:v)?2\./.test(version))
|
||||
plan.prerequisites.push('OSV-Scanner major version 2 is required.');
|
||||
return plan;
|
||||
});
|
||||
}
|
||||
@@ -258,7 +499,9 @@ function str(value: unknown): string {
|
||||
if (typeof value !== 'string' || value.length > 16_384) throw new Error('Expected bounded string');
|
||||
return value;
|
||||
}
|
||||
function optionalString(value: unknown): string | undefined { return value === undefined || value === null ? undefined : str(value); }
|
||||
function optionalString(value: unknown): string | undefined {
|
||||
return value === undefined || value === null ? undefined : str(value);
|
||||
}
|
||||
function integer(value: unknown): number | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 1) throw new Error('Invalid source coordinate');
|
||||
@@ -266,20 +509,32 @@ function integer(value: unknown): number | undefined {
|
||||
}
|
||||
function severity(value: unknown): ScannerCandidate['reportedSeverity'] {
|
||||
const normalized = typeof value === 'string' ? value.toLowerCase() : '';
|
||||
if (['critical', 'high', 'medium', 'low', 'info'].includes(normalized)) return normalized as ScannerCandidate['reportedSeverity'];
|
||||
return ({ error: 'high', warning: 'medium', note: 'info', informational: 'info', unknown: 'unknown' } as const)[normalized] ?? 'unknown';
|
||||
if (['critical', 'high', 'medium', 'low', 'info'].includes(normalized))
|
||||
return normalized as ScannerCandidate['reportedSeverity'];
|
||||
return (
|
||||
({ error: 'high', warning: 'medium', note: 'info', informational: 'info', unknown: 'unknown' } as const)[
|
||||
normalized
|
||||
] ?? 'unknown'
|
||||
);
|
||||
}
|
||||
|
||||
/** No path is opened by this module. Normalization refuses URI/traversal escapes. */
|
||||
export function scannerLocation(raw: string, sourceRoot: string): string {
|
||||
let decoded: string;
|
||||
try { decoded = decodeURIComponent(raw); } catch { throw new Error('Unsafe location'); }
|
||||
if (/[\x00-\x1f\x7f]/.test(decoded) || /%[\da-f]{2}/i.test(decoded) || decoded.includes('\\')) throw new Error('Unsafe location');
|
||||
try {
|
||||
decoded = decodeURIComponent(raw);
|
||||
} catch {
|
||||
throw new Error('Unsafe location');
|
||||
}
|
||||
if (/[\x00-\x1f\x7f]/.test(decoded) || /%[\da-f]{2}/i.test(decoded) || decoded.includes('\\'))
|
||||
throw new Error('Unsafe location');
|
||||
if (decoded.startsWith('file:')) {
|
||||
const url = new URL(decoded);
|
||||
if (url.hostname || url.username || url.password || url.search || url.hash) throw new Error('Unsafe file URI');
|
||||
if (url.hostname || url.username || url.password || url.search || url.hash)
|
||||
throw new Error('Unsafe file URI');
|
||||
decoded = decodeURIComponent(url.pathname);
|
||||
} else if (/^[a-z][a-z\d+.-]*:/i.test(decoded) || decoded.startsWith('//')) throw new Error('Unsafe location');
|
||||
} else if (/^[a-z][a-z\d+.-]*:/i.test(decoded) || decoded.startsWith('//'))
|
||||
throw new Error('Unsafe location');
|
||||
if (decoded.split('/').includes('..')) throw new Error('Unsafe location');
|
||||
const root = absolutePath(sourceRoot, 'sourceRoot');
|
||||
const absolute = decoded.startsWith('/') ? posix.normalize(decoded) : posix.join(root, decoded);
|
||||
@@ -314,32 +569,66 @@ function decodedDocument(raw: string): unknown {
|
||||
return document;
|
||||
}
|
||||
|
||||
function candidate(tool: ScannerCandidate['tool'], fields: Omit<ScannerCandidate, 'id' | 'tool' | 'evidence' | 'trust' | 'suppressed'> & { suppressed?: boolean }): ScannerCandidate {
|
||||
const identity = [tool, fields.ruleId, fields.location?.path ?? fields.operation ?? '', fields.location?.line ?? '', ...fields.advisoryIds.slice().sort()];
|
||||
function candidate(
|
||||
tool: ScannerCandidate['tool'],
|
||||
fields: Omit<ScannerCandidate, 'id' | 'tool' | 'evidence' | 'trust' | 'suppressed'> & {
|
||||
suppressed?: boolean;
|
||||
},
|
||||
): ScannerCandidate {
|
||||
const identity = [
|
||||
tool,
|
||||
fields.ruleId,
|
||||
fields.location?.path ?? fields.operation ?? '',
|
||||
fields.location?.line ?? '',
|
||||
...fields.advisoryIds.slice().sort(),
|
||||
];
|
||||
const id = createHash('sha256').update(JSON.stringify(identity)).digest('hex');
|
||||
return { ...fields, id, tool, suppressed: fields.suppressed ?? false, evidence: 'scanner-candidate', trust: 'untrusted' };
|
||||
return {
|
||||
...fields,
|
||||
id,
|
||||
tool,
|
||||
suppressed: fields.suppressed ?? false,
|
||||
evidence: 'scanner-candidate',
|
||||
trust: 'untrusted',
|
||||
};
|
||||
}
|
||||
|
||||
function location(path: unknown, line: unknown, column: unknown, root: string): ScannerCandidate['location'] {
|
||||
return { path: scannerLocation(str(path), root), line: integer(line), column: integer(column) };
|
||||
}
|
||||
|
||||
function parseSarif(document: unknown, tool: ScannerCandidate['tool'], root: string, add: (value: ScannerCandidate) => void, gap: (code: ScannerGap['code'], message: string) => void): void {
|
||||
function parseSarif(
|
||||
document: unknown,
|
||||
tool: ScannerCandidate['tool'],
|
||||
root: string,
|
||||
add: (value: ScannerCandidate) => void,
|
||||
gap: (code: ScannerGap['code'], message: string) => void,
|
||||
): void {
|
||||
const sarif = obj(document);
|
||||
if (sarif.version !== '2.1.0') throw new Error('SARIF 2.1.0 required');
|
||||
const runs = arr(sarif.runs);
|
||||
if (!runs.length) { gap('SKIPPED_INPUT', 'SARIF contains no assessment runs.'); return; }
|
||||
if (!runs.length) {
|
||||
gap('SKIPPED_INPUT', 'SARIF contains no assessment runs.');
|
||||
return;
|
||||
}
|
||||
for (const input of runs) {
|
||||
const run = obj(input); const driver = obj(obj(run.tool).driver);
|
||||
const run = obj(input);
|
||||
const driver = obj(obj(run.tool).driver);
|
||||
str(driver.name);
|
||||
if (run.externalPropertyFileReferences !== undefined) {
|
||||
const refs = obj(run.externalPropertyFileReferences);
|
||||
if (refs.results !== undefined && arr(refs.results).length) gap('SKIPPED_INPUT', 'External SARIF result files were not fetched or assessed.');
|
||||
if (refs.results !== undefined && arr(refs.results).length)
|
||||
gap('SKIPPED_INPUT', 'External SARIF result files were not fetched or assessed.');
|
||||
}
|
||||
for (const invocation of run.invocations === undefined ? [] : arr(run.invocations)) {
|
||||
const inv = obj(invocation);
|
||||
if (inv.executionSuccessful === false) gap('TOOL_FAILED', 'SARIF records an unsuccessful tool invocation.');
|
||||
if (Array.isArray(inv.toolExecutionNotifications) && inv.toolExecutionNotifications.some(n => obj(n).level === 'error')) gap('TOOL_FAILED', 'SARIF records tool execution errors.');
|
||||
if (inv.executionSuccessful === false)
|
||||
gap('TOOL_FAILED', 'SARIF records an unsuccessful tool invocation.');
|
||||
if (
|
||||
Array.isArray(inv.toolExecutionNotifications) &&
|
||||
inv.toolExecutionNotifications.some((n) => obj(n).level === 'error')
|
||||
)
|
||||
gap('TOOL_FAILED', 'SARIF records tool execution errors.');
|
||||
}
|
||||
const rules = driver.rules === undefined ? [] : arr(driver.rules);
|
||||
const results = arr(run.results);
|
||||
@@ -349,7 +638,10 @@ function parseSarif(document: unknown, tool: ScannerCandidate['tool'], root: str
|
||||
// SARIF also represents passing checks and informational inventory.
|
||||
if (['pass', 'notApplicable', 'informational'].includes(String(result.kind))) continue;
|
||||
const ruleIndex = result.ruleIndex;
|
||||
const rule = Number.isSafeInteger(ruleIndex) && (ruleIndex as number) >= 0 && rules[ruleIndex as number] ? obj(rules[ruleIndex as number]) : undefined;
|
||||
const rule =
|
||||
Number.isSafeInteger(ruleIndex) && (ruleIndex as number) >= 0 && rules[ruleIndex as number]
|
||||
? obj(rules[ruleIndex as number])
|
||||
: undefined;
|
||||
const ruleId = str(result.ruleId ?? rule?.id);
|
||||
const message = obj(result.message);
|
||||
let loc: ScannerCandidate['location'];
|
||||
@@ -358,7 +650,8 @@ function parseSarif(document: unknown, tool: ScannerCandidate['tool'], root: str
|
||||
let artifact = obj(physical.artifactLocation);
|
||||
if (artifact.uri === undefined && Number.isSafeInteger(artifact.index)) {
|
||||
const index = artifact.index as number;
|
||||
if (index < 0 || !Array.isArray(run.artifacts) || !run.artifacts[index]) throw new Error('Invalid artifact index');
|
||||
if (index < 0 || !Array.isArray(run.artifacts) || !run.artifacts[index])
|
||||
throw new Error('Invalid artifact index');
|
||||
artifact = obj(obj(run.artifacts[index]).location);
|
||||
}
|
||||
let uri = str(artifact.uri);
|
||||
@@ -373,21 +666,58 @@ function parseSarif(document: unknown, tool: ScannerCandidate['tool'], root: str
|
||||
loc = location(uri, region.startLine, region.startColumn, root);
|
||||
}
|
||||
const properties = result.properties === undefined ? {} : obj(result.properties);
|
||||
const aliases = properties.tags === undefined ? [] : arr(properties.tags).filter(v => typeof v === 'string' && /^(CVE-|GHSA-|OSV-)/.test(v));
|
||||
add(candidate(tool, { ruleId, message: str(message.text ?? message.markdown ?? message.id), location: loc, reportedSeverity: severity(result.level ?? (rule?.defaultConfiguration as Obj | undefined)?.level), advisoryIds: aliases as string[], suppressed: Array.isArray(result.suppressions) && result.suppressions.length > 0 }));
|
||||
} catch (error) { gap(error instanceof Error && /[Ll]ocation|URI|source root/.test(error.message) ? 'UNSAFE_LOCATION' : 'INVALID_OUTPUT', 'A SARIF result could not be safely normalized.'); }
|
||||
const aliases =
|
||||
properties.tags === undefined
|
||||
? []
|
||||
: arr(properties.tags).filter((v) => typeof v === 'string' && /^(CVE-|GHSA-|OSV-)/.test(v));
|
||||
add(
|
||||
candidate(tool, {
|
||||
ruleId,
|
||||
message: str(message.text ?? message.markdown ?? message.id),
|
||||
location: loc,
|
||||
reportedSeverity: severity(
|
||||
result.level ?? (rule?.defaultConfiguration as Obj | undefined)?.level,
|
||||
),
|
||||
advisoryIds: aliases as string[],
|
||||
suppressed: Array.isArray(result.suppressions) && result.suppressions.length > 0,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
gap(
|
||||
error instanceof Error && /[Ll]ocation|URI|source root/.test(error.message)
|
||||
? 'UNSAFE_LOCATION'
|
||||
: 'INVALID_OUTPUT',
|
||||
'A SARIF result could not be safely normalized.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseResults(plan: ScannerPlan, document: unknown, add: (value: ScannerCandidate) => void, gap: (code: ScannerGap['code'], message: string) => void): void {
|
||||
function parseResults(
|
||||
plan: ScannerPlan,
|
||||
document: unknown,
|
||||
add: (value: ScannerCandidate) => void,
|
||||
gap: (code: ScannerGap['code'], message: string) => void,
|
||||
): void {
|
||||
const root = plan.sourceRoot;
|
||||
if (plan.format === 'sarif') { parseSarif(document, plan.id, root, add, gap); return; }
|
||||
if (plan.format === 'sarif') {
|
||||
parseSarif(document, plan.id, root, add, gap);
|
||||
return;
|
||||
}
|
||||
if (plan.format === 'gitleaks-json') {
|
||||
for (const value of arr(document)) {
|
||||
const row = obj(value);
|
||||
// Never retain Match, Secret, Line, commit message, author, or scanner fingerprint.
|
||||
add(candidate(plan.id, { ruleId: str(row.RuleID), message: str(row.Description), reportedSeverity: 'unknown', location: location(row.File, row.StartLine, row.StartColumn, root), advisoryIds: [] }));
|
||||
add(
|
||||
candidate(plan.id, {
|
||||
ruleId: str(row.RuleID),
|
||||
message: str(row.Description),
|
||||
reportedSeverity: 'unknown',
|
||||
location: location(row.File, row.StartLine, row.StartColumn, root),
|
||||
advisoryIds: [],
|
||||
}),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -395,38 +725,93 @@ function parseResults(plan: ScannerPlan, document: unknown, add: (value: Scanner
|
||||
switch (plan.format) {
|
||||
case 'semgrep-json':
|
||||
for (const value of arr(doc.results)) {
|
||||
const row = obj(value), extra = obj(row.extra), start = obj(row.start);
|
||||
add(candidate(plan.id, { ruleId: str(row.check_id), message: str(extra.message), reportedSeverity: severity(extra.severity), location: location(row.path, start.line, start.col, root), advisoryIds: [], suppressed: extra.is_ignored === true }));
|
||||
const row = obj(value),
|
||||
extra = obj(row.extra),
|
||||
start = obj(row.start);
|
||||
add(
|
||||
candidate(plan.id, {
|
||||
ruleId: str(row.check_id),
|
||||
message: str(extra.message),
|
||||
reportedSeverity: severity(extra.severity),
|
||||
location: location(row.path, start.line, start.col, root),
|
||||
advisoryIds: [],
|
||||
suppressed: extra.is_ignored === true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (arr(doc.errors).length) gap('TOOL_FAILED', 'Semgrep reported parser, rule, or execution errors; inspect affected coverage.');
|
||||
if (arr(doc.errors).length)
|
||||
gap('TOOL_FAILED', 'Semgrep reported parser, rule, or execution errors; inspect affected coverage.');
|
||||
if (!arr(obj(doc.paths).scanned).length) gap('SKIPPED_INPUT', 'Semgrep did not scan any source files.');
|
||||
if (Array.isArray(obj(doc.paths).skipped) && (obj(doc.paths).skipped as unknown[]).length) gap('SKIPPED_INPUT', 'Semgrep skipped source files.');
|
||||
if (Array.isArray(obj(doc.paths).skipped) && (obj(doc.paths).skipped as unknown[]).length)
|
||||
gap('SKIPPED_INPUT', 'Semgrep skipped source files.');
|
||||
return;
|
||||
case 'osv-json':
|
||||
for (const value of arr(doc.results)) {
|
||||
const result = obj(value), source = obj(result.source);
|
||||
const result = obj(value),
|
||||
source = obj(result.source);
|
||||
for (const entry of arr(result.packages)) {
|
||||
const pkg = obj(entry), detail = obj(pkg.package);
|
||||
const pkg = obj(entry),
|
||||
detail = obj(pkg.package);
|
||||
for (const input of arr(pkg.vulnerabilities)) {
|
||||
const vuln = obj(input), id = str(vuln.id);
|
||||
const vuln = obj(input),
|
||||
id = str(vuln.id);
|
||||
const aliases = vuln.aliases === undefined ? [] : arr(vuln.aliases).map(str);
|
||||
add(candidate(plan.id, { ruleId: id, message: optionalString(vuln.summary) ?? id, reportedSeverity: 'unknown', location: location(source.path, undefined, undefined, root), advisoryIds: [...new Set([id, ...aliases])], dependency: { name: str(detail.name), version: optionalString(detail.version), ecosystem: optionalString(detail.ecosystem), reachability: 'unknown', exposure: 'unknown' } }));
|
||||
add(
|
||||
candidate(plan.id, {
|
||||
ruleId: id,
|
||||
message: optionalString(vuln.summary) ?? id,
|
||||
reportedSeverity: 'unknown',
|
||||
location: location(source.path, undefined, undefined, root),
|
||||
advisoryIds: [...new Set([id, ...aliases])],
|
||||
dependency: {
|
||||
name: str(detail.name),
|
||||
version: optionalString(detail.version),
|
||||
ecosystem: optionalString(detail.ecosystem),
|
||||
reachability: 'unknown',
|
||||
exposure: 'unknown',
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
case 'trivy-json':
|
||||
if (doc.SchemaVersion !== 2) throw new Error('Trivy schema version 2 required');
|
||||
if (doc.Results === undefined && (typeof doc.ArtifactName !== 'string' || doc.ArtifactType !== 'filesystem')) throw new Error('Missing Trivy assessment metadata');
|
||||
if (
|
||||
doc.Results === undefined &&
|
||||
(typeof doc.ArtifactName !== 'string' || doc.ArtifactType !== 'filesystem')
|
||||
)
|
||||
throw new Error('Missing Trivy assessment metadata');
|
||||
for (const value of arr(doc.Results ?? [])) {
|
||||
const result = obj(value);
|
||||
for (const key of ['Vulnerabilities', 'Misconfigurations', 'Secrets'] as const) {
|
||||
for (const input of result[key] === undefined ? [] : arr(result[key])) {
|
||||
const row = obj(input), id = str(row.VulnerabilityID ?? row.ID ?? row.RuleID);
|
||||
const row = obj(input),
|
||||
id = str(row.VulnerabilityID ?? row.ID ?? row.RuleID);
|
||||
const cause = row.CauseMetadata === undefined ? {} : obj(row.CauseMetadata);
|
||||
// Some filesystem package scanners add " (type)" after their target.
|
||||
const target = str(result.Target).replace(/ \([a-zA-Z0-9_. -]+\)$/, '');
|
||||
add(candidate(plan.id, { ruleId: id, message: optionalString(row.Title) ?? optionalString(row.Description) ?? id, reportedSeverity: severity(row.Severity), location: location(target, cause.StartLine ?? row.StartLine, undefined, root), advisoryIds: row.VulnerabilityID ? [id] : [], ...(key === 'Vulnerabilities' ? { dependency: { name: str(row.PkgName), version: optionalString(row.InstalledVersion), ecosystem: optionalString(result.Type), reachability: 'unknown' as const, exposure: 'unknown' as const } } : {}) }));
|
||||
add(
|
||||
candidate(plan.id, {
|
||||
ruleId: id,
|
||||
message: optionalString(row.Title) ?? optionalString(row.Description) ?? id,
|
||||
reportedSeverity: severity(row.Severity),
|
||||
location: location(target, cause.StartLine ?? row.StartLine, undefined, root),
|
||||
advisoryIds: row.VulnerabilityID ? [id] : [],
|
||||
...(key === 'Vulnerabilities'
|
||||
? {
|
||||
dependency: {
|
||||
name: str(row.PkgName),
|
||||
version: optionalString(row.InstalledVersion),
|
||||
ecosystem: optionalString(result.Type),
|
||||
reachability: 'unknown' as const,
|
||||
exposure: 'unknown' as const,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -434,13 +819,31 @@ function parseResults(plan: ScannerPlan, document: unknown, add: (value: Scanner
|
||||
case 'schemathesis-json': {
|
||||
str(doc.schemathesis_version);
|
||||
const operations = doc.operations === null ? null : obj(doc.operations);
|
||||
if (doc.complete !== true || doc.stop_reason !== 'completed') gap('SKIPPED_INPUT', 'Schemathesis did not finish its declared operation assessment.');
|
||||
if (!operations || typeof operations.tested !== 'number' || operations.tested === 0) gap('SKIPPED_INPUT', 'Schemathesis exercised no operations.');
|
||||
if (operations && (Number(operations.errored) > 0 || Number(operations.skipped) > 0 || Number(operations.tested) < Number(operations.selected))) gap('SKIPPED_INPUT', 'Schemathesis skipped or failed to exercise selected operations.');
|
||||
if (arr(doc.errors).length) gap('TOOL_FAILED', 'Schemathesis reported setup or test-generation errors.');
|
||||
if (doc.complete !== true || doc.stop_reason !== 'completed')
|
||||
gap('SKIPPED_INPUT', 'Schemathesis did not finish its declared operation assessment.');
|
||||
if (!operations || typeof operations.tested !== 'number' || operations.tested === 0)
|
||||
gap('SKIPPED_INPUT', 'Schemathesis exercised no operations.');
|
||||
if (
|
||||
operations &&
|
||||
(Number(operations.errored) > 0 ||
|
||||
Number(operations.skipped) > 0 ||
|
||||
Number(operations.tested) < Number(operations.selected))
|
||||
)
|
||||
gap('SKIPPED_INPUT', 'Schemathesis skipped or failed to exercise selected operations.');
|
||||
if (arr(doc.errors).length)
|
||||
gap('TOOL_FAILED', 'Schemathesis reported setup or test-generation errors.');
|
||||
for (const value of arr(doc.failures)) {
|
||||
const row = obj(value);
|
||||
for (const op of arr(row.operations)) add(candidate(plan.id, { ruleId: str(row.type), message: str(row.title), reportedSeverity: severity(row.severity), advisoryIds: [], operation: str(op) }));
|
||||
for (const op of arr(row.operations))
|
||||
add(
|
||||
candidate(plan.id, {
|
||||
ruleId: str(row.type),
|
||||
message: str(row.title),
|
||||
reportedSeverity: severity(row.severity),
|
||||
advisoryIds: [],
|
||||
operation: str(op),
|
||||
}),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -450,16 +853,38 @@ function parseResults(plan: ScannerPlan, document: unknown, add: (value: Scanner
|
||||
/** Failed or malformed tools never become an empty-clean assessment. */
|
||||
export function parseScannerOutput(plan: ScannerPlan, execution: ScannerExecution): ScannerOutcome {
|
||||
const outcome: ScannerOutcome = {
|
||||
tool: plan.id, version: null, status: 'not_assessed', candidates: [], gaps: [], scope: plan.coverage.scope.slice(), exclusions: plan.coverage.exclusions.slice(),
|
||||
databaseUpdatedAt: null, exitCode: execution.exitCode, evidence: 'scanner-candidate', provenanceSources: plan.provenanceSources.slice(),
|
||||
planSha256: createHash('sha256').update(JSON.stringify(plan)).digest('hex'), documentationInspectedAt: plan.documentationInspectedAt,
|
||||
tool: plan.id,
|
||||
version: null,
|
||||
status: 'not_assessed',
|
||||
candidates: [],
|
||||
gaps: [],
|
||||
scope: plan.coverage.scope.slice(),
|
||||
exclusions: plan.coverage.exclusions.slice(),
|
||||
databaseUpdatedAt: null,
|
||||
exitCode: execution.exitCode,
|
||||
evidence: 'scanner-candidate',
|
||||
provenanceSources: plan.provenanceSources.slice(),
|
||||
planSha256: createHash('sha256').update(JSON.stringify(plan)).digest('hex'),
|
||||
documentationInspectedAt: plan.documentationInspectedAt,
|
||||
};
|
||||
const gap = (code: ScannerGap['code'], message: string) => { if (!outcome.gaps.some(g => g.code === code && g.message === message)) outcome.gaps.push({ code, message }); };
|
||||
if (execution.unavailable) { gap('UNAVAILABLE', `${plan.id} was unavailable; this scanner assessment did not run.`); return outcome; }
|
||||
if (plan.prerequisites.length) { for (const value of plan.prerequisites) gap('PREREQUISITE', value); return outcome; }
|
||||
const gap = (code: ScannerGap['code'], message: string) => {
|
||||
if (!outcome.gaps.some((g) => g.code === code && g.message === message))
|
||||
outcome.gaps.push({ code, message });
|
||||
};
|
||||
if (execution.unavailable) {
|
||||
gap('UNAVAILABLE', `${plan.id} was unavailable; this scanner assessment did not run.`);
|
||||
return outcome;
|
||||
}
|
||||
if (plan.prerequisites.length) {
|
||||
for (const value of plan.prerequisites) gap('PREREQUISITE', value);
|
||||
return outcome;
|
||||
}
|
||||
if (execution.timedOut) gap('TIMEOUT', 'Scanner exceeded its execution deadline.');
|
||||
const outputBytes = Buffer.byteLength(execution.stdout) + Buffer.byteLength(execution.stderr ?? '');
|
||||
if (execution.truncated || outputBytes > Math.min(plan.maxOutputBytes, MAX_SCANNER_OUTPUT_BYTES)) { gap('OUTPUT_LIMIT', 'Scanner output exceeded the capture limit; payload withheld.'); return outcome; }
|
||||
if (execution.truncated || outputBytes > Math.min(plan.maxOutputBytes, MAX_SCANNER_OUTPUT_BYTES)) {
|
||||
gap('OUTPUT_LIMIT', 'Scanner output exceeded the capture limit; payload withheld.');
|
||||
return outcome;
|
||||
}
|
||||
try {
|
||||
if (execution.version) {
|
||||
const safe = redactFindingSpans(execution.version);
|
||||
@@ -467,31 +892,61 @@ export function parseScannerOutput(plan: ScannerPlan, execution: ScannerExecutio
|
||||
outcome.version = safe.slice(0, 200).replace(/[\x00-\x1f\x7f]/g, '');
|
||||
}
|
||||
if (redactFindingSpans(execution.stderr ?? '') === null) throw new RedactionFailure();
|
||||
if (/\b(?:error|fatal|panic|failed to|unable to|no offline version)\b/i.test(execution.stderr ?? '')) gap('TOOL_FAILED', 'Scanner diagnostic output reported a failure; the JSON result does not establish complete coverage.');
|
||||
if (/\b(?:error|fatal|panic|failed to|unable to|no offline version)\b/i.test(execution.stderr ?? ''))
|
||||
gap(
|
||||
'TOOL_FAILED',
|
||||
'Scanner diagnostic output reported a failure; the JSON result does not establish complete coverage.',
|
||||
);
|
||||
const doc = decodedDocument(execution.stdout);
|
||||
const seen = new Set<string>();
|
||||
parseResults(plan, doc, item => {
|
||||
if (outcome.candidates.length >= MAX_CANDIDATES) throw new Error('Candidate limit exceeded');
|
||||
if (!seen.has(item.id)) { seen.add(item.id); outcome.candidates.push(item); }
|
||||
}, gap);
|
||||
parseResults(
|
||||
plan,
|
||||
doc,
|
||||
(item) => {
|
||||
if (outcome.candidates.length >= MAX_CANDIDATES) throw new Error('Candidate limit exceeded');
|
||||
if (!seen.has(item.id)) {
|
||||
seen.add(item.id);
|
||||
outcome.candidates.push(item);
|
||||
}
|
||||
},
|
||||
gap,
|
||||
);
|
||||
outcome.status = 'complete';
|
||||
} catch (error) {
|
||||
if (error instanceof RedactionFailure) { outcome.candidates = []; gap('REDACTION_FAILED', 'Scanner payload could not be safely redacted and was withheld.'); }
|
||||
else gap('INVALID_OUTPUT', 'Scanner report is malformed, unsupported, or exceeds structural limits.');
|
||||
if (error instanceof RedactionFailure) {
|
||||
outcome.candidates = [];
|
||||
gap('REDACTION_FAILED', 'Scanner payload could not be safely redacted and was withheld.');
|
||||
} else gap('INVALID_OUTPUT', 'Scanner report is malformed, unsupported, or exceeds structural limits.');
|
||||
}
|
||||
const successCodes = plan.id === 'gitleaks' ? [0, 10] : ['osv', 'schemathesis'].includes(plan.id) ? [0, 1] : [0];
|
||||
if (execution.exitCode === null || !successCodes.includes(execution.exitCode)) gap('TOOL_FAILED', 'Scanner did not exit with a recognized assessment status.');
|
||||
if ((plan.id === 'gitleaks' && execution.exitCode === 10 || plan.id === 'osv' && execution.exitCode === 1) && !outcome.candidates.length) gap('INVALID_OUTPUT', 'Scanner finding exit status disagrees with its empty report.');
|
||||
const successCodes =
|
||||
plan.id === 'gitleaks' ? [0, 10] : ['osv', 'schemathesis'].includes(plan.id) ? [0, 1] : [0];
|
||||
if (execution.exitCode === null || !successCodes.includes(execution.exitCode))
|
||||
gap('TOOL_FAILED', 'Scanner did not exit with a recognized assessment status.');
|
||||
if (
|
||||
((plan.id === 'gitleaks' && execution.exitCode === 10) ||
|
||||
(plan.id === 'osv' && execution.exitCode === 1)) &&
|
||||
!outcome.candidates.length
|
||||
)
|
||||
gap('INVALID_OUTPUT', 'Scanner finding exit status disagrees with its empty report.');
|
||||
if (['osv', 'trivy'].includes(plan.id)) {
|
||||
if (execution.databaseUpdatedAt && /^\d{4}-\d\d-\d\dT/.test(execution.databaseUpdatedAt) && Number.isFinite(Date.parse(execution.databaseUpdatedAt))) outcome.databaseUpdatedAt = execution.databaseUpdatedAt;
|
||||
if (
|
||||
execution.databaseUpdatedAt &&
|
||||
/^\d{4}-\d\d-\d\dT/.test(execution.databaseUpdatedAt) &&
|
||||
Number.isFinite(Date.parse(execution.databaseUpdatedAt))
|
||||
)
|
||||
outcome.databaseUpdatedAt = execution.databaseUpdatedAt;
|
||||
else gap('UNKNOWN_FRESHNESS', 'The advisory database freshness is unknown.');
|
||||
}
|
||||
if (outcome.gaps.length) outcome.status = outcome.status === 'complete' || outcome.candidates.length ? 'partial' : 'not_assessed';
|
||||
if (outcome.gaps.length)
|
||||
outcome.status = outcome.status === 'complete' || outcome.candidates.length ? 'partial' : 'not_assessed';
|
||||
return outcome;
|
||||
}
|
||||
|
||||
/** Import CodeQL or other SARIF as read-only candidates; never trust its verdict. */
|
||||
export function importSarif(raw: string, opts: { sourceRoot: string; version?: string; scope?: string[] }): ScannerOutcome {
|
||||
export function importSarif(
|
||||
raw: string,
|
||||
opts: { sourceRoot: string; version?: string; scope?: string[] },
|
||||
): ScannerOutcome {
|
||||
const root = absolutePath(opts.sourceRoot, 'sourceRoot');
|
||||
const plan = scannerPlans({ snapshotRoot: root, offline: true, selected: ['zizmor'] })[0];
|
||||
plan.coverage.scope = opts.scope ?? [root];
|
||||
@@ -499,6 +954,6 @@ export function importSarif(raw: string, opts: { sourceRoot: string; version?: s
|
||||
plan.coverage.exclusions = ['Imported scanner scope and suppressions require independent validation.'];
|
||||
const outcome = parseScannerOutput(plan, { stdout: raw, exitCode: 0, version: opts.version });
|
||||
outcome.tool = 'sarif';
|
||||
outcome.candidates = outcome.candidates.map(item => candidate('sarif', item));
|
||||
outcome.candidates = outcome.candidates.map((item) => candidate('sarif', item));
|
||||
return outcome;
|
||||
}
|
||||
Reference in new issue
Block a user