diff --git a/config/schemas/scan-target-batch-plan.schema.json b/config/schemas/scan-target-batch-plan.schema.json new file mode 100644 index 0000000..8a32869 --- /dev/null +++ b/config/schemas/scan-target-batch-plan.schema.json @@ -0,0 +1,49 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://socioprophet.org/schemas/scan-target-batch-plan.schema.json", + "title": "SCOPE-D Scan Target Batch Plan", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "batchPlanId", + "sourceBatchRef", + "policyRef", + "createdAt", + "targetCount", + "allowedCount", + "blockedCount", + "targets", + "executionAllowed", + "executionPerformed" + ], + "properties": { + "schemaVersion": { "type": "string", "const": "0.1.0" }, + "batchPlanId": { "type": "string", "pattern": "^scan-target-batch-plan:[a-z0-9][a-z0-9._:-]*$" }, + "sourceBatchRef": { "type": "string", "minLength": 1 }, + "policyRef": { "type": "string", "minLength": 1 }, + "createdAt": { "type": "string", "format": "date-time" }, + "targetCount": { "type": "integer", "minimum": 1 }, + "allowedCount": { "type": "integer", "minimum": 0 }, + "blockedCount": { "type": "integer", "minimum": 0 }, + "targets": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/targetPlan" } }, + "executionAllowed": { "type": "boolean", "const": false }, + "executionPerformed": { "type": "boolean", "const": false } + }, + "$defs": { + "targetPlan": { + "type": "object", + "additionalProperties": false, + "required": ["targetRef", "surfaceKind", "status", "requestRef", "decisionRef", "planRef", "blockedMethods"], + "properties": { + "targetRef": { "type": "string", "minLength": 1 }, + "surfaceKind": { "type": "string", "enum": ["mcp_discovery", "github_posture", "k8s_manifest", "ai_runtime", "network_boundary", "web_endpoint"] }, + "status": { "type": "string", "enum": ["allow_plan", "hard_block"] }, + "requestRef": { "type": "string", "minLength": 1 }, + "decisionRef": { "type": "string", "minLength": 1 }, + "planRef": { "type": ["string", "null"] }, + "blockedMethods": { "type": "array", "items": { "type": "string" } } + } + } + } +} diff --git a/config/schemas/scan-target-batch.schema.json b/config/schemas/scan-target-batch.schema.json new file mode 100644 index 0000000..8fe0b36 --- /dev/null +++ b/config/schemas/scan-target-batch.schema.json @@ -0,0 +1,46 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://socioprophet.org/schemas/scan-target-batch.schema.json", + "title": "SCOPE-D Scan Target Batch", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "batchId", + "operatorId", + "policyRef", + "targets", + "requestedMethods", + "requestedAt", + "scanExecutionRequested", + "credentialAccessRequested", + "payloadDeliveryRequested", + "mutationRequested", + "batchFailureMode" + ], + "properties": { + "schemaVersion": { "type": "string", "const": "0.1.0" }, + "batchId": { "type": "string", "pattern": "^scan-target-batch:[a-z0-9][a-z0-9._:-]*$" }, + "operatorId": { "type": "string", "minLength": 1 }, + "policyRef": { "type": "string", "minLength": 1 }, + "targets": { "type": "array", "minItems": 1, "maxItems": 25, "items": { "$ref": "#/$defs/target" } }, + "requestedMethods": { "type": "array", "minItems": 1, "items": { "type": "string", "enum": ["passive_metadata", "dns_lookup", "tls_certificate_read", "http_head", "tcp_connect", "service_banner_read", "exploit_attempt", "credential_access", "payload_delivery", "brute_force", "destructive_probe", "service_disruption"] } }, + "requestedAt": { "type": "string", "format": "date-time" }, + "scanExecutionRequested": { "type": "boolean" }, + "credentialAccessRequested": { "type": "boolean", "const": false }, + "payloadDeliveryRequested": { "type": "boolean", "const": false }, + "mutationRequested": { "type": "boolean", "const": false }, + "batchFailureMode": { "type": "string", "enum": ["block_entire_batch", "block_invalid_targets"] } + }, + "$defs": { + "target": { + "type": "object", + "additionalProperties": false, + "required": ["targetRef", "surfaceKind"], + "properties": { + "targetRef": { "type": "string", "minLength": 1 }, + "surfaceKind": { "type": "string", "enum": ["mcp_discovery", "github_posture", "k8s_manifest", "ai_runtime", "network_boundary", "web_endpoint"] } + } + } + } +} diff --git a/examples/scope-d/scan-target-batch.example.json b/examples/scope-d/scan-target-batch.example.json new file mode 100644 index 0000000..0c1d7a1 --- /dev/null +++ b/examples/scope-d/scan-target-batch.example.json @@ -0,0 +1,23 @@ +{ + "schemaVersion": "0.1.0", + "batchId": "scan-target-batch:example-web-targets", + "operatorId": "operator:michael", + "policyRef": "examples/scope-d/operator-scan-policy.example.json", + "targets": [ + { + "targetRef": "example.com", + "surfaceKind": "web_endpoint" + }, + { + "targetRef": "unauthorized.example.net", + "surfaceKind": "web_endpoint" + } + ], + "requestedMethods": ["passive_metadata", "dns_lookup", "http_head"], + "requestedAt": "2026-05-22T18:30:00Z", + "scanExecutionRequested": true, + "credentialAccessRequested": false, + "payloadDeliveryRequested": false, + "mutationRequested": false, + "batchFailureMode": "block_invalid_targets" +} diff --git a/scripts/plan-batch-scan.js b/scripts/plan-batch-scan.js new file mode 100644 index 0000000..a4fdc59 --- /dev/null +++ b/scripts/plan-batch-scan.js @@ -0,0 +1,165 @@ +#!/usr/bin/env node +'use strict'; + +const cp = require('child_process'); +const fs = require('fs'); +const path = require('path'); +const Ajv = require('ajv/dist/2020'); +const addFormats = require('ajv-formats'); + +const ROOT = path.resolve(__dirname, '..'); +const BATCH_SCHEMA = 'config/schemas/scan-target-batch.schema.json'; +const BATCH_PLAN_SCHEMA = 'config/schemas/scan-target-batch-plan.schema.json'; +const SINGLE_PLANNER = path.join(ROOT, 'scripts', 'plan-operator-scan.js'); + +function usage() { + console.log('Usage: node scripts/plan-batch-scan.js [--policy ] [--out-dir ]'); +} + +function parseArgs(argv) { + const args = { batch: null, policy: null, outDir: null }; + for (let i = 2; i < argv.length; i += 1) { + const item = argv[i]; + if (item === '--help' || item === '-h') { usage(); process.exit(0); } + if (item === '--policy') { args.policy = argv[++i]; continue; } + if (item === '--out-dir') { args.outDir = argv[++i]; continue; } + if (!args.batch) { args.batch = item; continue; } + throw new Error(`Unknown argument: ${item}`); + } + if (!args.batch) throw new Error('Missing scan target batch path.'); + return args; +} + +function abs(file) { + return path.isAbsolute(file) ? file : path.join(ROOT, file); +} + +function rel(file) { + return path.relative(ROOT, file).replace(/\\/g, '/'); +} + +function readJson(file) { + return JSON.parse(fs.readFileSync(abs(file), 'utf8')); +} + +function writeJson(file, value) { + const out = abs(file); + fs.mkdirSync(path.dirname(out), { recursive: true }); + fs.writeFileSync(out, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); +} + +function validate(schemaRel, value, label) { + const schema = readJson(schemaRel); + const ajv = new Ajv({ allErrors: true, strict: false }); + addFormats(ajv); + const validator = ajv.compile(schema); + if (!validator(value)) { + const details = (validator.errors || []).map((err) => `${err.instancePath || '/'} ${err.message}`).join('; '); + throw new Error(`${label} failed schema validation: ${details}`); + } +} + +function slug(value) { + return String(value).toLowerCase().replace(/[^a-z0-9._:-]+/g, '-').replace(/^-+|-+$/g, '') || 'batch'; +} + +function buildRequest(batch, target) { + return { + schemaVersion: '0.1.0', + requestId: `operator-scan-request:${slug(batch.batchId.replace(/^scan-target-batch:/, ''))}-${slug(target.targetRef)}`, + operatorId: batch.operatorId, + policyRef: batch.policyRef, + targetRef: target.targetRef, + surfaceKind: target.surfaceKind, + requestedMethods: batch.requestedMethods, + requestedAt: batch.requestedAt, + scanExecutionRequested: batch.scanExecutionRequested, + credentialAccessRequested: batch.credentialAccessRequested, + payloadDeliveryRequested: batch.payloadDeliveryRequested, + mutationRequested: batch.mutationRequested, + }; +} + +function runSinglePlanner(requestPath, policyPath, outDir) { + const result = cp.spawnSync(process.execPath, [SINGLE_PLANNER, requestPath, '--policy', policyPath, '--out-dir', outDir], { + cwd: ROOT, + encoding: 'utf8', + stdio: 'pipe', + }); + if (![0, 2].includes(result.status)) { + throw new Error(`single-target planner failed with ${result.status}: ${result.stderr || result.stdout}`); + } + try { + return JSON.parse(result.stdout); + } catch (err) { + throw new Error(`single-target planner emitted invalid JSON: ${err.message}`); + } +} + +function main() { + const args = parseArgs(process.argv); + const batchPath = abs(args.batch); + const batch = readJson(batchPath); + validate(BATCH_SCHEMA, batch, 'scan target batch'); + const policyPath = abs(args.policy || batch.policyRef); + const outDir = abs(args.outDir || path.join('runs', `scan-target-batch-${slug(batch.batchId.replace(/^scan-target-batch:/, ''))}`)); + if (fs.existsSync(outDir)) throw new Error(`Output directory already exists: ${rel(outDir)}`); + fs.mkdirSync(outDir, { recursive: true }); + + const targetPlans = []; + for (const target of batch.targets) { + const targetDir = path.join(outDir, 'targets', slug(target.targetRef)); + fs.mkdirSync(targetDir, { recursive: true }); + const request = buildRequest(batch, target); + const requestPath = path.join(targetDir, 'operator-scan-request.json'); + writeJson(requestPath, request); + const result = runSinglePlanner(requestPath, policyPath, targetDir); + targetPlans.push({ + targetRef: target.targetRef, + surfaceKind: target.surfaceKind, + status: result.decision.decision, + requestRef: rel(requestPath), + decisionRef: rel(path.join(targetDir, 'operator-scan-decision.json')), + planRef: result.planRef ? rel(path.join(targetDir, 'operator-scan-plan.json')) : null, + blockedMethods: result.decision.blockedMethods || [], + }); + } + + const allowedCount = targetPlans.filter((item) => item.status === 'allow_plan').length; + const blockedCount = targetPlans.filter((item) => item.status === 'hard_block').length; + if (batch.batchFailureMode === 'block_entire_batch' && blockedCount > 0) { + for (const item of targetPlans) { + if (item.status === 'allow_plan') { + item.status = 'hard_block'; + item.blockedMethods = ['batch_blocked_due_to_invalid_target']; + item.planRef = null; + } + } + } + + const finalAllowed = targetPlans.filter((item) => item.status === 'allow_plan').length; + const finalBlocked = targetPlans.filter((item) => item.status === 'hard_block').length; + const batchPlan = { + schemaVersion: '0.1.0', + batchPlanId: `scan-target-batch-plan:${slug(batch.batchId.replace(/^scan-target-batch:/, ''))}`, + sourceBatchRef: rel(batchPath), + policyRef: rel(policyPath), + createdAt: new Date().toISOString(), + targetCount: targetPlans.length, + allowedCount: finalAllowed, + blockedCount: finalBlocked, + targets: targetPlans, + executionAllowed: false, + executionPerformed: false, + }; + validate(BATCH_PLAN_SCHEMA, batchPlan, 'scan target batch plan'); + writeJson(path.join(outDir, 'scan-target-batch-plan.json'), batchPlan); + process.stdout.write(`${JSON.stringify(batchPlan, null, 2)}\n`); +} + +try { + main(); +} catch (err) { + console.error(`plan-batch-scan failed: ${err.message}`); + process.exit(1); +} diff --git a/scripts/test-batch-scan-planning.js b/scripts/test-batch-scan-planning.js new file mode 100644 index 0000000..6d3cbff --- /dev/null +++ b/scripts/test-batch-scan-planning.js @@ -0,0 +1,75 @@ +#!/usr/bin/env node +'use strict'; + +const cp = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const ROOT = path.resolve(__dirname, '..'); +const PLANNER = path.join(ROOT, 'scripts', 'plan-batch-scan.js'); +const BATCH = path.join(ROOT, 'examples', 'scope-d', 'scan-target-batch.example.json'); +const POLICY = path.join(ROOT, 'examples', 'scope-d', 'operator-scan-policy.example.json'); + +function fail(message, result) { + console.error(message); + if (result) { + if (result.stdout) console.error(`stdout:\n${result.stdout}`); + if (result.stderr) console.error(`stderr:\n${result.stderr}`); + } + process.exit(1); +} + +function parseJson(text, label, result) { + try { return JSON.parse(text); } catch (err) { fail(`${label}: expected JSON output: ${err.message}`, result); } +} + +const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'scope-d-batch-scan-')); +const mixedDir = path.join(tmpDir, 'mixed'); +const wholeDir = path.join(tmpDir, 'whole'); +const wholeBatchPath = path.join(tmpDir, 'scan-target-batch.block-entire.json'); + +const mixed = cp.spawnSync(process.execPath, [PLANNER, BATCH, '--policy', POLICY, '--out-dir', mixedDir], { + cwd: ROOT, + encoding: 'utf8', + stdio: 'pipe', +}); +if (mixed.status !== 0) fail(`Expected mixed batch planning success, got ${mixed.status}`, mixed); +const mixedPlan = parseJson(mixed.stdout, 'mixed batch plan', mixed); +if (mixedPlan.targetCount !== 2) fail(`Expected 2 targets, got ${mixedPlan.targetCount}.`, mixed); +if (mixedPlan.allowedCount !== 1) fail(`Expected 1 allowed target, got ${mixedPlan.allowedCount}.`, mixed); +if (mixedPlan.blockedCount !== 1) fail(`Expected 1 blocked target, got ${mixedPlan.blockedCount}.`, mixed); +if (mixedPlan.executionAllowed !== false || mixedPlan.executionPerformed !== false) fail('Expected batch execution flags false.', mixed); +const allowedTarget = mixedPlan.targets.find((target) => target.targetRef === 'example.com'); +const blockedTarget = mixedPlan.targets.find((target) => target.targetRef === 'unauthorized.example.net'); +if (!allowedTarget || allowedTarget.status !== 'allow_plan' || !allowedTarget.planRef) fail('Expected example.com allow_plan with planRef.', mixed); +if (!blockedTarget || blockedTarget.status !== 'hard_block' || blockedTarget.planRef !== null) fail('Expected unauthorized target hard_block without planRef.', mixed); +if (!blockedTarget.blockedMethods.includes('target_not_authorized')) fail('Expected target_not_authorized block.', mixed); +for (const target of mixedPlan.targets) { + if (!fs.existsSync(path.join(ROOT, target.requestRef))) fail(`Missing request artifact ${target.requestRef}.`, mixed); + if (!fs.existsSync(path.join(ROOT, target.decisionRef))) fail(`Missing decision artifact ${target.decisionRef}.`, mixed); +} +if (!fs.existsSync(path.join(mixedDir, 'scan-target-batch-plan.json'))) fail('Expected mixed batch plan artifact.', mixed); + +const wholeBatch = JSON.parse(fs.readFileSync(BATCH, 'utf8')); +wholeBatch.batchId = 'scan-target-batch:example-web-targets-block-entire'; +wholeBatch.batchFailureMode = 'block_entire_batch'; +fs.writeFileSync(wholeBatchPath, `${JSON.stringify(wholeBatch, null, 2)}\n`, 'utf8'); +const whole = cp.spawnSync(process.execPath, [PLANNER, wholeBatchPath, '--policy', POLICY, '--out-dir', wholeDir], { + cwd: ROOT, + encoding: 'utf8', + stdio: 'pipe', +}); +if (whole.status !== 0) fail(`Expected whole-batch planning success, got ${whole.status}`, whole); +const wholePlan = parseJson(whole.stdout, 'whole batch plan', whole); +if (wholePlan.targetCount !== 2) fail(`Expected 2 whole-batch targets, got ${wholePlan.targetCount}.`, whole); +if (wholePlan.allowedCount !== 0) fail(`Expected 0 allowed targets after whole-batch block, got ${wholePlan.allowedCount}.`, whole); +if (wholePlan.blockedCount !== 2) fail(`Expected 2 blocked targets after whole-batch block, got ${wholePlan.blockedCount}.`, whole); +if (!wholePlan.targets.some((target) => target.blockedMethods.includes('batch_blocked_due_to_invalid_target'))) fail('Expected whole-batch block marker.', whole); +for (const target of wholePlan.targets) { + if (target.status !== 'hard_block') fail('Expected every whole-batch target to be hard_block.', whole); + if (target.planRef !== null) fail('Expected whole-batch blocked targets to have null planRef.', whole); +} + +fs.rmSync(tmpDir, { recursive: true, force: true }); +console.log('Batch scan planning tests passed.'); diff --git a/scripts/test-operator-scan-planning.js b/scripts/test-operator-scan-planning.js index 08136f8..7db7a67 100644 --- a/scripts/test-operator-scan-planning.js +++ b/scripts/test-operator-scan-planning.js @@ -10,6 +10,7 @@ const ROOT = path.resolve(__dirname, '..'); const PLANNER = path.join(ROOT, 'scripts', 'plan-operator-scan.js'); const GATE_TEST = path.join(ROOT, 'scripts', 'test-operator-capability-gates.js'); const BINDING_TEST = path.join(ROOT, 'scripts', 'test-scan-capability-binding.js'); +const BATCH_TEST = path.join(ROOT, 'scripts', 'test-batch-scan-planning.js'); const VALID_REQUEST = path.join(ROOT, 'examples', 'scope-d', 'operator-scan-request.example.json'); const BLOCKED_REQUEST = path.join(ROOT, 'fixtures', 'invalid', 'operator-scan-request-exploit.invalid.json'); const POLICY = path.join(ROOT, 'examples', 'scope-d', 'operator-scan-policy.example.json'); @@ -33,6 +34,9 @@ if (gate.status !== 0) fail(`Expected capability gate tests success, got ${gate. const binding = cp.spawnSync(process.execPath, [BINDING_TEST], { cwd: ROOT, encoding: 'utf8', stdio: 'pipe' }); if (binding.status !== 0) fail(`Expected scan capability binding tests success, got ${binding.status}`, binding); +const batch = cp.spawnSync(process.execPath, [BATCH_TEST], { cwd: ROOT, encoding: 'utf8', stdio: 'pipe' }); +if (batch.status !== 0) fail(`Expected batch scan planning tests success, got ${batch.status}`, batch); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'scope-d-scan-plan-')); const allowDir = path.join(tmpDir, 'allow'); const blockDir = path.join(tmpDir, 'block');