Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions config/schemas/scan-target-batch-plan.schema.json
Original file line number Diff line number Diff line change
@@ -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" } }
}
}
}
}
46 changes: 46 additions & 0 deletions config/schemas/scan-target-batch.schema.json
Original file line number Diff line number Diff line change
@@ -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"] }
}
}
}
}
23 changes: 23 additions & 0 deletions examples/scope-d/scan-target-batch.example.json
Original file line number Diff line number Diff line change
@@ -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"
}
165 changes: 165 additions & 0 deletions scripts/plan-batch-scan.js
Original file line number Diff line number Diff line change
@@ -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 <scan-target-batch.json> [--policy <operator-scan-policy.json>] [--out-dir <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);
}
75 changes: 75 additions & 0 deletions scripts/test-batch-scan-planning.js
Original file line number Diff line number Diff line change
@@ -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.');
4 changes: 4 additions & 0 deletions scripts/test-operator-scan-planning.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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');
Expand Down
Loading