@clear-capabilities/agentic-security-scanner 0.175.0 → 0.177.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/CHANGELOG.md +25 -0
- package/bin/agentic-security.js +206 -28
- package/dist/1919.index.js +1 -1
- package/dist/2066.index.js +851 -0
- package/dist/2199.index.js +1 -1
- package/dist/2584.index.js +3350 -0
- package/dist/2923.index.js +13 -0
- package/dist/2946.index.js +81 -0
- package/dist/3196.index.js +27 -0
- package/dist/3727.index.js +292 -0
- package/dist/37d64df006576248d0d8.json +286 -0
- package/dist/4168.index.js +509 -0
- package/dist/4281.index.js +993 -0
- package/dist/5099.index.js +96 -0
- package/dist/5456.index.js +291 -0
- package/dist/5616.index.js +1471 -0
- package/dist/5620.index.js +2494 -0
- package/dist/5817.index.js +714 -0
- package/dist/5937.index.js +672 -0
- package/dist/6333.index.js +98 -0
- package/dist/6655.index.js +3221 -0
- package/dist/7838.index.js +491 -974
- package/dist/818.index.js +4 -3
- package/dist/83.index.js +159 -0
- package/dist/8551.index.js +82 -0
- package/dist/8633.index.js +93 -0
- package/dist/8714.index.js +76 -76
- package/dist/8825.index.js +84 -0
- package/dist/932.index.js +186 -0
- package/dist/9560.index.js +358 -19
- package/dist/9800.index.js +6 -1
- package/dist/agentic-security.mjs +10 -10
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/b4289e728659450b5e19.js +32 -0
- package/package.json +8 -4
- package/src/artifact/CLAUDE.md +4 -0
- package/src/artifact/artifact-evidence.js +63 -0
- package/src/artifact/cosign.js +61 -0
- package/src/artifact/engine-source.js +72 -0
- package/src/artifact/oci-layout.js +47 -0
- package/src/artifact/registry.js +213 -0
- package/src/capabilities/tool-registry.js +2 -0
- package/src/lineage/CLAUDE.md +4 -0
- package/src/lineage/identity-token.js +11 -0
- package/src/lineage/observation-adapters.js +13 -0
- package/src/lineage/runtime-edge-states.js +139 -0
- package/src/lineage/runtime-explorer-view.js +82 -0
- package/src/lineage/runtime-observation.js +11 -0
- package/src/mcp/CLAUDE.md +3 -1
- package/src/mcp/proof-tools.js +132 -0
- package/src/mcp/server.js +1 -1
- package/src/mcp/tools.js +3 -1
- package/src/posture/CLAUDE.md +23 -0
- package/src/posture/artifact-registry.js +4 -0
- package/src/posture/custom-rules.js +54 -8
- package/src/posture/dnr/attack-facts.js +31 -0
- package/src/posture/dnr/cli.js +138 -0
- package/src/posture/dnr/detection-ir.js +123 -0
- package/src/posture/dnr/exploit-correlation.js +25 -0
- package/src/posture/dnr/exposure-window.js +134 -0
- package/src/posture/dnr/hunt.js +84 -0
- package/src/posture/dnr/log-reader.js +189 -0
- package/src/posture/dnr/matchers.js +90 -0
- package/src/posture/dnr/outputs.js +262 -0
- package/src/posture/dnr/payload-classes.js +120 -0
- package/src/posture/dnr/redact.js +25 -0
- package/src/posture/dnr/store.js +57 -0
- package/src/posture/dnr/validate.js +64 -0
- package/src/posture/dnr/view.js +51 -0
- package/src/posture/evaluation/lab/arm-ollama.js +88 -0
- package/src/posture/evaluation/lab/arms.js +142 -0
- package/src/posture/evaluation/lab/cli.js +135 -0
- package/src/posture/evaluation/lab/dataset.js +139 -0
- package/src/posture/evaluation/lab/materialize.js +44 -0
- package/src/posture/evaluation/lab/neutralize.js +144 -0
- package/src/posture/evaluation/lab/report.js +194 -0
- package/src/posture/evaluation/lab/run.js +225 -0
- package/src/posture/evaluation/lab/score.js +135 -0
- package/src/posture/evaluation/lab/scoreboard.js +48 -0
- package/src/posture/evaluation/lab/stats.js +41 -0
- package/src/posture/evaluation/lab/store.js +53 -0
- package/src/posture/fleet/cli.js +260 -63
- package/src/posture/fleet/discover-edges.js +135 -0
- package/src/posture/fleet/enterprise-graph.js +189 -0
- package/src/posture/fleet/fleet-graph.js +9 -4
- package/src/posture/fleet/fleet-manifest.js +50 -5
- package/src/posture/fleet/job-runner.js +153 -0
- package/src/posture/fleet/job-store.js +138 -0
- package/src/posture/fleet/lineage-summary.js +55 -0
- package/src/posture/fleet/org-health.js +56 -0
- package/src/posture/fleet/scan-cache.js +112 -0
- package/src/posture/fleet/scan-child.js +32 -0
- package/src/posture/proof-fabric/cli.js +34 -6
- package/src/posture/proof-fabric/container.js +57 -0
- package/src/posture/proof-fabric/fix-proof.js +2 -1
- package/src/posture/proof-fabric/generate-python.js +129 -0
- package/src/posture/proof-fabric/generate.js +214 -0
- package/src/posture/proof-fabric/reproducer.js +3 -0
- package/src/posture/proof-fabric/runner.js +44 -13
- package/src/posture/proof-fabric/siblings.js +77 -0
- package/src/posture/routing/policy-router.js +103 -0
- package/src/posture/rule-compiler/cli.js +101 -9
- package/src/posture/rule-compiler/corpus.js +13 -9
- package/src/posture/rule-compiler/ir.js +52 -9
- package/src/posture/rule-compiler/mutation.js +72 -1
- package/src/posture/rule-compiler/promotion.js +21 -5
- package/src/posture/rule-compiler/proposal.js +137 -0
- package/src/posture/rule-compiler/regression.js +95 -0
- package/src/posture/rule-compiler/store.js +45 -1
- package/src/posture/rule-pack-signing.js +4 -2
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// A bounded summary of one repository's Code Boundaries graph (the Data Flow Explorer's `DataFlowGraph v1`), for the enterprise graph
|
|
2
|
+
// (PRD 4: "federate Code Boundaries across repositories"). The whole graph is not copied: a fleet of hundreds of repositories does not
|
|
3
|
+
// need each node, only what crosses a repository boundary or changes what the organization knows about it:
|
|
4
|
+
// * the data classes that appear in the code (so a repository holding payment data is known to, even if the manifest forgot to say);
|
|
5
|
+
// * the external destinations the code sends data to, by host;
|
|
6
|
+
// * the queue topics it publishes to or consumes from;
|
|
7
|
+
// * the stores it touches, by host/database/table name;
|
|
8
|
+
// * counts, including flows that carry sensitive classes across a trust boundary.
|
|
9
|
+
// Pure; hostile input (the graph came out of a scan of someone's code), so every string is bounded and shape-checked.
|
|
10
|
+
const MAX_ITEMS = 64;
|
|
11
|
+
const SENSITIVE = new Set(['pii', 'phi', 'pci', 'fin', 'credentials']);
|
|
12
|
+
const arr = (v) => (Array.isArray(v) ? v : []);
|
|
13
|
+
const clean = (v, max = 120) => (typeof v === 'string' && v.length > 0 && v.length <= max && !/[\s\u0000-\u001f]/.test(v) ? v : null);
|
|
14
|
+
|
|
15
|
+
function hostOf(literal) {
|
|
16
|
+
if (typeof literal !== 'string') return null;
|
|
17
|
+
try { const h = new URL(literal).hostname.toLowerCase(); return clean(h, 253); } catch { /* not a URL */ }
|
|
18
|
+
const bare = literal.toLowerCase().replace(/:\d+$/, '');
|
|
19
|
+
return /^[a-z0-9][a-z0-9.-]{0,252}$/.test(bare) ? bare : null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function summarizeLineage(graph) {
|
|
23
|
+
if (!graph || typeof graph !== 'object') return null;
|
|
24
|
+
const classes = new Set();
|
|
25
|
+
for (const d of arr(graph.dataElements)) for (const c of arr(d?.dataClasses)) { if (typeof c !== 'string') continue; const l = clean(c.toLowerCase(), 31); if (l && /^[a-z][a-z0-9-]*$/.test(l)) classes.add(l); }
|
|
26
|
+
const ext = new Map(); const queues = new Map(); const stores = new Map();
|
|
27
|
+
for (const n of arr(graph.nodes)) {
|
|
28
|
+
if (!n || typeof n !== 'object') continue;
|
|
29
|
+
if (n.kind === 'external') {
|
|
30
|
+
const host = hostOf(n.destination?.literalValue);
|
|
31
|
+
const key = `${n.subtype ?? 'external'}|${host ?? ''}`;
|
|
32
|
+
if (!ext.has(key) && ext.size < MAX_ITEMS) ext.set(key, { subtype: clean(n.subtype, 60) ?? 'external', host });
|
|
33
|
+
} else if (n.kind === 'queue') {
|
|
34
|
+
const topic = clean(n.queueDetail?.topic, 120); const op = n.queueDetail?.operation === 'consume' || n.queueDetail?.operation === 'receive' ? 'consume' : 'publish';
|
|
35
|
+
if (topic && queues.size < MAX_ITEMS) queues.set(`${topic}|${op}`, { topic, operation: op });
|
|
36
|
+
} else if (n.kind === 'store') {
|
|
37
|
+
const d = n.storeDetail ?? {};
|
|
38
|
+
const item = { subtype: clean(n.subtype, 60) ?? 'store', host: hostOf(d.host), database: clean(d.database, 120), table: clean(d.table, 120) };
|
|
39
|
+
const key = JSON.stringify(item);
|
|
40
|
+
if (stores.size < MAX_ITEMS) stores.set(key, item);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const edges = arr(graph.edges);
|
|
44
|
+
const crossing = new Set(edges.filter((e) => arr(e?.boundaryCrossings).length).map((e) => e.id));
|
|
45
|
+
const dataById = new Map(arr(graph.dataElements).map((d) => [d?.id, d]));
|
|
46
|
+
const sensitiveCrossing = arr(graph.flows).filter((f) => arr(f?.edgeIds).some((id) => crossing.has(id)) && arr(f?.dataElementIds).some((id) => arr(dataById.get(id)?.dataClasses).some((c) => SENSITIVE.has(String(c).toLowerCase())))).length;
|
|
47
|
+
return {
|
|
48
|
+
schema: 'agentic-security/fleet-lineage-summary', version: 1,
|
|
49
|
+
dataClasses: [...classes].sort().slice(0, MAX_ITEMS),
|
|
50
|
+
externalDestinations: [...ext.values()],
|
|
51
|
+
queues: [...queues.values()],
|
|
52
|
+
stores: [...stores.values()],
|
|
53
|
+
counts: { nodes: arr(graph.nodes).length, edges: edges.length, flows: arr(graph.flows).length, boundaryCrossingEdges: crossing.size, sensitiveFlowsCrossingBoundary: sensitiveCrossing },
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Organization-level scan health (PRD 4, requirement 9). Pure.
|
|
2
|
+
//
|
|
3
|
+
// Repositories requested: 1,250
|
|
4
|
+
// Complete: 1,228 Partial: 11 Failed: 6 Timed out: 5 Not run: ...
|
|
5
|
+
//
|
|
6
|
+
// The one rule: an organization is not GREEN while a required repository has no usable result. A repository that was requested but
|
|
7
|
+
// has no job, was cancelled, is still queued or leased, failed or timed out is counted under its own heading and can never disappear
|
|
8
|
+
// from the total. `partial` (the scan finished but the engine itself reported a degraded scan) keeps the organization out of GREEN
|
|
9
|
+
// as well, because a partial scan is not evidence of a clean repository.
|
|
10
|
+
//
|
|
11
|
+
// green every required repository has a complete result and no optional repository has a problem
|
|
12
|
+
// degraded every required repository has a result, but some result is partial, or an optional repository has a problem
|
|
13
|
+
// red at least one required repository has no result
|
|
14
|
+
import { isTerminal } from './job-store.js';
|
|
15
|
+
|
|
16
|
+
export const ASSURANCE = Object.freeze(['green', 'degraded', 'red']);
|
|
17
|
+
|
|
18
|
+
export function computeOrgHealth({ manifest, jobs }) {
|
|
19
|
+
const byRepo = new Map(jobs.map((j) => [j.repoId, j]));
|
|
20
|
+
const counts = { requested: manifest.repositories.length, complete: 0, partial: 0, failed: 0, timedOut: 0, cancelled: 0, notRun: 0 };
|
|
21
|
+
const repos = [];
|
|
22
|
+
for (const r of manifest.repositories) {
|
|
23
|
+
const j = byRepo.get(r.id);
|
|
24
|
+
let state;
|
|
25
|
+
if (!j) state = 'not-run';
|
|
26
|
+
else if (j.status === 'succeeded') state = 'complete';
|
|
27
|
+
else if (j.status === 'partial') state = 'partial';
|
|
28
|
+
else if (j.status === 'failed') state = 'failed';
|
|
29
|
+
else if (j.status === 'timed_out') state = 'timed-out';
|
|
30
|
+
else if (j.status === 'cancelled') state = 'cancelled';
|
|
31
|
+
else state = 'not-run'; // queued or leased: no result yet
|
|
32
|
+
counts[{ complete: 'complete', partial: 'partial', failed: 'failed', 'timed-out': 'timedOut', cancelled: 'cancelled', 'not-run': 'notRun' }[state]]++;
|
|
33
|
+
repos.push({ id: r.id, required: r.required, state, attempts: j?.attempts ?? 0, error: j?.error ?? null, cached: !!j?.summary?.cached, findings: j?.summary?.total ?? null, jobStatus: j?.status ?? null, finished: j ? isTerminal(j.status) : false });
|
|
34
|
+
}
|
|
35
|
+
const noResult = (x) => !['complete', 'partial'].includes(x.state);
|
|
36
|
+
const requiredMissing = repos.filter((x) => x.required && noResult(x));
|
|
37
|
+
const requiredPartial = repos.filter((x) => x.required && x.state === 'partial');
|
|
38
|
+
const optionalProblems = repos.filter((x) => !x.required && x.state !== 'complete');
|
|
39
|
+
const assurance = requiredMissing.length ? 'red' : (requiredPartial.length || optionalProblems.length) ? 'degraded' : 'green';
|
|
40
|
+
const reasons = [
|
|
41
|
+
...(requiredMissing.length ? [`${requiredMissing.length} required ${requiredMissing.length === 1 ? 'repository has' : 'repositories have'} no result: ${requiredMissing.slice(0, 8).map((x) => `${x.id} (${x.state})`).join(', ')}${requiredMissing.length > 8 ? ', ...' : ''}`] : []),
|
|
42
|
+
...(requiredPartial.length ? [`${requiredPartial.length} required ${requiredPartial.length === 1 ? 'repository was' : 'repositories were'} only partially scanned: ${requiredPartial.map((x) => x.id).join(', ')}`] : []),
|
|
43
|
+
...(optionalProblems.length ? [`${optionalProblems.length} optional ${optionalProblems.length === 1 ? 'repository has' : 'repositories have'} a problem: ${optionalProblems.map((x) => `${x.id} (${x.state})`).join(', ')}`] : []),
|
|
44
|
+
];
|
|
45
|
+
return { counts, assurance, reasons, repositories: repos };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const n = (x) => x.toLocaleString('en-US');
|
|
49
|
+
export function renderOrgHealth(h) {
|
|
50
|
+
const c = h.counts;
|
|
51
|
+
const L = [`Repositories requested: ${n(c.requested)}`, `Complete: ${n(c.complete)}`, `Partial: ${n(c.partial)}`, `Failed: ${n(c.failed)}`, `Timed out: ${n(c.timedOut)}`];
|
|
52
|
+
if (c.cancelled) L.push(`Cancelled: ${n(c.cancelled)}`);
|
|
53
|
+
if (c.notRun) L.push(`Not run: ${n(c.notRun)}`);
|
|
54
|
+
L.push('', `Assurance: ${h.assurance.toUpperCase()}`, ...h.reasons.map((r) => ` ${r}`));
|
|
55
|
+
return L.join('\n');
|
|
56
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// The content-addressed scan cache (PRD 4, requirement 5). A repository's scan result is reused only when EVERYTHING that could
|
|
2
|
+
// change it is unchanged: the commit (and any uncommitted change), the lockfiles, the scan configuration and the scanner itself.
|
|
3
|
+
//
|
|
4
|
+
// key = sha256( repository fingerprint, lockfile digests, configuration, scanner identity )
|
|
5
|
+
//
|
|
6
|
+
// Anything that cannot be determined makes the key `null`, and a null key means "do not cache": an unknown input must never be
|
|
7
|
+
// treated as an unchanged one. The scanner identity is the package version plus the SHA-256 recorded for the shipped bundle, so a
|
|
8
|
+
// change to scanner logic changes the key. A source checkout with no built bundle has no such identity and therefore never reuses
|
|
9
|
+
// a cached result.
|
|
10
|
+
//
|
|
11
|
+
// A cached record carries its own digest; one that was edited, truncated or does not match its key is a miss, never a hit.
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { createHash } from 'node:crypto';
|
|
15
|
+
import { spawnSync } from 'node:child_process';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
import { STATE_DIR_NAME } from '../state-dir.js';
|
|
18
|
+
|
|
19
|
+
const LOCKFILES = Object.freeze(['package-lock.json', 'npm-shrinkwrap.json', 'yarn.lock', 'pnpm-lock.yaml', 'go.sum', 'Cargo.lock', 'Pipfile.lock', 'poetry.lock', 'requirements.txt', 'Gemfile.lock', 'composer.lock', 'flake.lock', 'cabal.project.freeze', 'stack.yaml.lock']);
|
|
20
|
+
const SKIP = new Set(['.git', 'node_modules', STATE_DIR_NAME]);
|
|
21
|
+
const sha = (s) => createHash('sha256').update(s).digest('hex');
|
|
22
|
+
const MAX_FILES = 5000; const MAX_FILE_BYTES = 2 * 1024 * 1024; const MAX_TOTAL_BYTES = 128 * 1024 * 1024;
|
|
23
|
+
|
|
24
|
+
function git(repo, args, timeout = 10000) {
|
|
25
|
+
const r = spawnSync('git', ['-C', repo, ...args], { encoding: 'utf8', timeout, killSignal: 'SIGKILL', maxBuffer: 32 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
26
|
+
return r.error || r.status !== 0 ? null : r.stdout;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function treeDigest(root) {
|
|
30
|
+
const h = createHash('sha256'); let files = 0; let total = 0; let complete = true;
|
|
31
|
+
const walk = (d, rel) => {
|
|
32
|
+
let ents = [];
|
|
33
|
+
try { ents = fs.readdirSync(d, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name)); } catch { complete = false; return; }
|
|
34
|
+
for (const e of ents) {
|
|
35
|
+
if (!complete) return;
|
|
36
|
+
if (SKIP.has(e.name)) continue;
|
|
37
|
+
const p = path.join(d, e.name); const r = rel ? `${rel}/${e.name}` : e.name;
|
|
38
|
+
if (e.isSymbolicLink()) { h.update(`L ${r}\0`); continue; }
|
|
39
|
+
if (e.isDirectory()) { walk(p, r); continue; }
|
|
40
|
+
if (!e.isFile()) continue;
|
|
41
|
+
if (++files > MAX_FILES) { complete = false; return; }
|
|
42
|
+
let st; try { st = fs.statSync(p); } catch { complete = false; return; }
|
|
43
|
+
total += st.size;
|
|
44
|
+
if (total > MAX_TOTAL_BYTES) { complete = false; return; }
|
|
45
|
+
if (st.size > MAX_FILE_BYTES) { h.update(`B ${r} ${st.size}\0`); continue; }
|
|
46
|
+
try { h.update(`F ${r}\0${sha(fs.readFileSync(p))}\0`); } catch { complete = false; return; }
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
walk(root, '');
|
|
50
|
+
return complete ? h.digest('hex') : null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** What identifies this repository's content right now, or null parts where it cannot be determined. */
|
|
54
|
+
export function repoFingerprint(repoPath) {
|
|
55
|
+
const head = git(repoPath, ['rev-parse', 'HEAD']);
|
|
56
|
+
const commit = head ? head.trim() : null;
|
|
57
|
+
let content = null; let dirty = null;
|
|
58
|
+
if (commit) {
|
|
59
|
+
const status = git(repoPath, ['status', '--porcelain=v1', '-uall']);
|
|
60
|
+
dirty = status == null ? null : status.trim().length > 0;
|
|
61
|
+
if (dirty === false) content = `git:${commit}`;
|
|
62
|
+
else if (dirty === true) { const t = treeDigest(repoPath); content = t ? `git+worktree:${commit}:${t}` : null; }
|
|
63
|
+
} else { const t = treeDigest(repoPath); content = t ? `tree:${t}` : null; }
|
|
64
|
+
const lock = {};
|
|
65
|
+
for (const f of LOCKFILES) { try { const st = fs.statSync(path.join(repoPath, f)); if (st.isFile() && st.size <= 32 * 1024 * 1024) lock[f] = sha(fs.readFileSync(path.join(repoPath, f))); } catch { /* absent */ } }
|
|
66
|
+
return { commit, dirty, content, lockfiles: lock };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Package version + the bundle's recorded hash. Null when the scanner cannot be identified (a source checkout), which disables the cache. */
|
|
70
|
+
export function scannerIdentity() {
|
|
71
|
+
try {
|
|
72
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
73
|
+
for (const base of [path.join(here, '..', '..', '..'), path.join(here, '..', '..', '..', '..')]) {
|
|
74
|
+
const pkg = path.join(base, 'package.json');
|
|
75
|
+
const sidecar = path.join(base, 'dist', 'agentic-security.mjs.sha256');
|
|
76
|
+
if (fs.existsSync(pkg) && fs.existsSync(sidecar)) {
|
|
77
|
+
const version = JSON.parse(fs.readFileSync(pkg, 'utf8')).version;
|
|
78
|
+
const digest = fs.readFileSync(sidecar, 'utf8').trim().split(/\s+/)[0];
|
|
79
|
+
if (version && /^[0-9a-f]{64}$/i.test(digest)) return `${version}+${digest}`;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
} catch { /* unknown */ }
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function cacheKey({ fingerprint, config, identity }) {
|
|
87
|
+
if (!fingerprint?.content || !identity) return null;
|
|
88
|
+
return sha(JSON.stringify({ c: fingerprint.content, l: fingerprint.lockfiles, cfg: config, id: identity }));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function readCache(dir, key) {
|
|
92
|
+
if (!key || !/^[0-9a-f]{64}$/.test(key)) return null;
|
|
93
|
+
try {
|
|
94
|
+
const doc = JSON.parse(fs.readFileSync(path.join(dir, `${key}.json`), 'utf8'));
|
|
95
|
+
if (doc.key !== key) return null;
|
|
96
|
+
const { digest, ...rest } = doc;
|
|
97
|
+
return digest === sha(JSON.stringify(rest)) ? doc : null;
|
|
98
|
+
} catch { return null; }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function writeCache(dir, key, payload) {
|
|
102
|
+
if (!key || !/^[0-9a-f]{64}$/.test(key)) return false;
|
|
103
|
+
try {
|
|
104
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
105
|
+
const body = { schema: 'agentic-security/fleet-scan-cache', version: 1, key, ...payload };
|
|
106
|
+
const doc = { ...body, digest: sha(JSON.stringify(body)) };
|
|
107
|
+
const tmp = path.join(dir, `.${key}.${process.pid}.tmp`);
|
|
108
|
+
fs.writeFileSync(tmp, JSON.stringify(doc), { mode: 0o600 });
|
|
109
|
+
fs.renameSync(tmp, path.join(dir, `${key}.json`));
|
|
110
|
+
return true;
|
|
111
|
+
} catch { return false; }
|
|
112
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Child-process entry for ONE repository's scan inside a fleet run (PRD 4, requirement 4: one hung repository must not stop the
|
|
2
|
+
// fleet). `node scan-child.js <repoPath>`. It exists as its own process so the controller can kill a scan that does not finish,
|
|
3
|
+
// together with everything the scan started; an in-process scan cannot be interrupted.
|
|
4
|
+
//
|
|
5
|
+
// It runs the scan through the existing `runFleet` for a single repository, so the per-repository entry (finding counts, owners
|
|
6
|
+
// from CODEOWNERS, scan health, policy drift, provenance debt) is exactly what a fleet entry has always been, and prints that entry
|
|
7
|
+
// plus a slim copy of the findings between markers. Scan state writes are disabled (`AGENTIC_SECURITY_NO_STATE=1`, set by the
|
|
8
|
+
// controller): scanning a repository for the fleet must not change that repository.
|
|
9
|
+
import { runFleet } from '../fleet.js';
|
|
10
|
+
import { runScan } from '../../runScan.js';
|
|
11
|
+
import { summarizeLineage } from './lineage-summary.js';
|
|
12
|
+
|
|
13
|
+
export const BEGIN = '@@FLEET-SCAN-BEGIN@@';
|
|
14
|
+
export const END = '@@FLEET-SCAN-END@@';
|
|
15
|
+
const MAX_FINDINGS = 5000;
|
|
16
|
+
|
|
17
|
+
const slim = (f) => ({ id: f.id ?? null, stableId: f.stableId ?? null, severity: f.severity ?? null, cwe: f.cwe ?? null, family: f.family ?? null, vuln: f.vuln ?? null, file: f.file ?? null, line: Number.isInteger(f.line) ? f.line : null, parser: f.parser ?? null });
|
|
18
|
+
|
|
19
|
+
async function main(repoPath) {
|
|
20
|
+
let captured = null;
|
|
21
|
+
const res = await runFleet({ repos: [repoPath], concurrency: 1, resume: false, runScan: async (p) => { const r = await runScan(p, { provenance: false }); captured = r.scan; return r; } });
|
|
22
|
+
const entry = res.results?.[0];
|
|
23
|
+
if (!entry) throw new Error('the scan produced no entry');
|
|
24
|
+
const findings = (captured?.findings ?? []).slice(0, MAX_FINDINGS).map(slim);
|
|
25
|
+
// the Code Boundaries graph exists only when the controller asked for it (AGENTIC_SECURITY_LINEAGE_DEEP=1); it is summarized, never copied whole
|
|
26
|
+
const lineage = process.env.AGENTIC_SECURITY_LINEAGE_DEEP === '1' ? summarizeLineage(captured?.lineageGraph) : null;
|
|
27
|
+
process.stdout.write(`${BEGIN}${JSON.stringify({ entry, findings, findingsTruncated: (captured?.findings?.length ?? 0) > MAX_FINDINGS, lineage })}${END}`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href) {
|
|
31
|
+
main(process.argv[2]).then(() => process.exit(0), (e) => { process.stderr.write(String(e?.stack || e).slice(0, 4000)); process.exit(1); });
|
|
32
|
+
}
|
|
@@ -10,6 +10,7 @@ import { saveProof, latestProof, listProofs, findingKey, scrubDeep } from './sto
|
|
|
10
10
|
import { selectEnvironment, hostTools, languageSupport } from './runner.js';
|
|
11
11
|
import { validateReproducer } from './reproducer.js';
|
|
12
12
|
import { LANGUAGES } from './reproducer.js';
|
|
13
|
+
import { generateReproducer } from './generate.js';
|
|
13
14
|
|
|
14
15
|
function loadFinding(root, id) {
|
|
15
16
|
const p = statePath(root, 'last-scan.json');
|
|
@@ -18,7 +19,17 @@ function loadFinding(root, id) {
|
|
|
18
19
|
return (last.findings || []).find((f) => f.id === id || f.stableId === id) ?? null;
|
|
19
20
|
}
|
|
20
21
|
|
|
21
|
-
|
|
22
|
+
/**
|
|
23
|
+
* The reproducer for a finding: the one the person or agent supplied (or stored), else one built from the finding's own evidence.
|
|
24
|
+
* A generated reproducer is a proposal; it earns nothing until it is executed, and the result says it was generated.
|
|
25
|
+
*/
|
|
26
|
+
export function resolveReproducer(root, finding, flagPath) {
|
|
27
|
+
const supplied = loadReproducer(root, finding.stableId ?? finding.id, flagPath) ?? (finding.stableId && finding.id !== finding.stableId ? loadReproducer(root, finding.id, flagPath) : null);
|
|
28
|
+
if (supplied) return { ...supplied, generated: null };
|
|
29
|
+
const g = generateReproducer({ finding, root });
|
|
30
|
+
if (!g.ok) return { reproducer: null, reason: g.reason };
|
|
31
|
+
return { reproducer: g.reproducer, file: null, generated: g.basis };
|
|
32
|
+
}
|
|
22
33
|
|
|
23
34
|
function loadReproducer(root, id, flagPath) {
|
|
24
35
|
const candidates = [flagPath && path.resolve(String(flagPath)), statePath(root, 'reproducers', `${findingKey(id)}.json`)].filter(Boolean);
|
|
@@ -49,8 +60,9 @@ export async function cmdProve(args, root, { json = false } = {}) {
|
|
|
49
60
|
if (!id) { console.error('usage: triage exploit <finding-id> --prove [--reproducer file] [--attempts N] [--required N]'); return 4; }
|
|
50
61
|
const finding = loadFinding(root, id);
|
|
51
62
|
if (!finding) { console.error(`Finding ${id} not found in last scan.`); return 4; }
|
|
52
|
-
const loaded =
|
|
53
|
-
if (!loaded) { console.error(`No reproducer for ${id}. Supply one with --reproducer <file> or place it at .agentic-security/reproducers/. Nothing was executed.`); return 4; }
|
|
63
|
+
const loaded = resolveReproducer(root, finding, args.flags.reproducer);
|
|
64
|
+
if (!loaded.reproducer) { console.error(`No reproducer for ${id}, and none could be generated from the finding: ${loaded.reason}. Supply one with --reproducer <file> or place it at .agentic-security/reproducers/. Nothing was executed.`); return 4; }
|
|
65
|
+
if (loaded.generated) console.error(`Generated a reproducer from the finding (${loaded.generated.shape}, ${loaded.generated.class}). It is a proposal: it counts only if executing it confirms the exploit.`);
|
|
54
66
|
const errs = validateReproducer(loaded.reproducer);
|
|
55
67
|
if (errs.length) { console.error(`Invalid reproducer: ${errs.join('; ')}`); return 4; }
|
|
56
68
|
const policy = {};
|
|
@@ -62,8 +74,8 @@ export async function cmdProve(args, root, { json = false } = {}) {
|
|
|
62
74
|
const onSig = () => ac.abort();
|
|
63
75
|
process.once('SIGINT', onSig);
|
|
64
76
|
let result;
|
|
65
|
-
try { result = await proveFinding({ finding, reproducer: loaded.reproducer, revision: args.flags.revision ?? null, policy, signal: ac.signal }); } finally { process.off('SIGINT', onSig); }
|
|
66
|
-
const saved = saveProof(root, { ...result, reproducer: loaded.reproducer });
|
|
77
|
+
try { result = await proveFinding({ finding, reproducer: loaded.reproducer, revision: args.flags.revision ?? null, policy, signal: ac.signal, sourceRoot: root }); } finally { process.off('SIGINT', onSig); }
|
|
78
|
+
const saved = saveProof(root, { ...result, reproducer: loaded.reproducer, ...(loaded.generated ? { generatedFrom: loaded.generated } : {}) });
|
|
67
79
|
// Printed output gets the same secret-scrubbing pass as what is persisted (defense in depth: a free-text reason/note
|
|
68
80
|
// field that captured something from stdout/stderr should never reach a terminal unredacted either).
|
|
69
81
|
if (json || args.flags.json) console.log(JSON.stringify(scrubDeep({ ...result, stored: saved.ok ? saved.file : null }), null, 2));
|
|
@@ -74,8 +86,19 @@ export async function cmdProve(args, root, { json = false } = {}) {
|
|
|
74
86
|
export async function cmdProof(args, root) {
|
|
75
87
|
const sub = args._[1];
|
|
76
88
|
if (sub === 'doctor') return doctor(args);
|
|
89
|
+
if (sub === 'generate') {
|
|
90
|
+
const gid = String(args._[2] ?? '');
|
|
91
|
+
const finding = gid ? loadFinding(root, gid) : null;
|
|
92
|
+
if (!finding) { console.error(gid ? `Finding ${gid} not found in last scan.` : 'usage: proof generate <finding-id> [--out file]'); return 4; }
|
|
93
|
+
const g = generateReproducer({ finding, root });
|
|
94
|
+
if (!g.ok) { console.error(`No reproducer could be generated: ${g.reason}`); return 1; }
|
|
95
|
+
const text = JSON.stringify({ ...g.reproducer, findingId: gid }, null, 2);
|
|
96
|
+
if (args.flags.out) { fs.writeFileSync(path.resolve(String(args.flags.out)), `${text}\n`); console.log(`Wrote ${path.resolve(String(args.flags.out))} (${g.basis.shape}, ${g.basis.class}). Nothing was executed.`); } else console.log(text);
|
|
97
|
+
for (const a of g.basis.assumptions) console.error(`assumption: ${a}`);
|
|
98
|
+
return 0;
|
|
99
|
+
}
|
|
77
100
|
const id = String(args._[2] ?? '');
|
|
78
|
-
if (!id) { console.error('usage: proof show <finding-id> | proof replay <finding-id> | proof doctor'); return 4; }
|
|
101
|
+
if (!id) { console.error('usage: proof show <finding-id> | proof replay <finding-id> | proof generate <finding-id> | proof doctor'); return 4; }
|
|
79
102
|
if (sub === 'show') {
|
|
80
103
|
const all = listProofs(root, id);
|
|
81
104
|
if (!all.length) { console.error(`No stored proof for ${id}.`); return 4; }
|
|
@@ -84,6 +107,11 @@ export async function cmdProof(args, root) {
|
|
|
84
107
|
const good = all.find((p) => p.record && !p.tampered);
|
|
85
108
|
if (!good) { console.error('No intact proof record.'); return 1; }
|
|
86
109
|
if (args.flags.json) console.log(JSON.stringify(good.record, null, 2)); else console.log(formatResult(good.record));
|
|
110
|
+
// the detection generated from this finding's proof (detection-and-response loop), when there is one
|
|
111
|
+
try {
|
|
112
|
+
const det = (await import('../dnr/store.js')).latestRecord(root, id, 'detection');
|
|
113
|
+
if (det && !args.flags.json) console.log(`Detection: ${det.record.ir?.id ?? 'unknown'} (${det.record.validation?.status ?? 'not validated'}${det.record.enabled ? ', enabled' : ''})`);
|
|
114
|
+
} catch { /* the proof output does not depend on it */ }
|
|
87
115
|
void latest;
|
|
88
116
|
return 0;
|
|
89
117
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Container execution for the proof fabric (PRD 1, requirement 2). The runner prefers a container when a responsive runtime and a
|
|
2
|
+
// locally present image exist, and it never pulls an image: nothing is fetched during a proof.
|
|
3
|
+
//
|
|
4
|
+
// What the container is allowed to be, by construction (`containerArgs`, pure and tested flag by flag):
|
|
5
|
+
// * no network at all (`--network none`); a reproducer that needs the loopback callback listener cannot run in a container and says so;
|
|
6
|
+
// * not privileged, no capabilities (`--cap-drop ALL`), no privilege escalation (`no-new-privileges`), a read-only root filesystem;
|
|
7
|
+
// * bounded CPU, memory (swap = memory), process count and a small writable /tmp;
|
|
8
|
+
// * one bind mount: the proof's own scratch directory, as `/work`. Nothing of the host's home, repository or credentials;
|
|
9
|
+
// * the host user's uid and gid, never root;
|
|
10
|
+
// * the environment is exactly the reproducer's, plus a minimal PATH/HOME.
|
|
11
|
+
// The container is named, and removed (`rm -f`) on every exit path including timeout and cancellation, because killing the `docker`
|
|
12
|
+
// client does not by itself stop the container.
|
|
13
|
+
//
|
|
14
|
+
// HONEST LIMIT: this has been exercised against a fake engine that records the arguments and runs the command locally, which proves
|
|
15
|
+
// the plumbing and the flags. It has not been run against a real Docker or Podman daemon in this repository's test environment.
|
|
16
|
+
import { spawnSync } from 'node:child_process';
|
|
17
|
+
import { superviseSpawn } from '../../sandbox/supervise.js';
|
|
18
|
+
import { scrubEnv } from '../../sandbox/trust-domains.js';
|
|
19
|
+
|
|
20
|
+
export const CONTAINER_ROOT = '/work';
|
|
21
|
+
// the interpreter names inside the image; the host paths the runner uses locally mean nothing in there
|
|
22
|
+
export const CONTAINER_TOOLS = Object.freeze({ node: 'node', python: 'python3', java: 'java', javac: 'javac', cc: 'gcc', dotnet: 'dotnet' });
|
|
23
|
+
|
|
24
|
+
export function containerArgs({ image, name, hostRoot, argv, env = {}, limits, user = null }) {
|
|
25
|
+
if (!image || !name || !hostRoot || !Array.isArray(argv) || !argv.length) throw new Error('container run needs image, name, root and argv');
|
|
26
|
+
const uid = user ?? (typeof process.getuid === 'function' ? `${process.getuid()}:${process.getgid()}` : '65534:65534');
|
|
27
|
+
const args = [
|
|
28
|
+
'run', '--rm', '--pull', 'never', '--name', name,
|
|
29
|
+
'--network', 'none', '--read-only', '--cap-drop', 'ALL', '--security-opt', 'no-new-privileges',
|
|
30
|
+
'--pids-limit', String(limits.pids), '--memory', `${limits.memoryMb}m`, '--memory-swap', `${limits.memoryMb}m`, '--cpus', String(limits.cpus),
|
|
31
|
+
'--tmpfs', `/tmp:rw,nosuid,size=${limits.diskMb}m`, '--user', uid,
|
|
32
|
+
'-v', `${hostRoot}:${CONTAINER_ROOT}:rw`, '--workdir', CONTAINER_ROOT,
|
|
33
|
+
'-e', 'HOME=/work', '-e', 'PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin', '-e', 'CI=1',
|
|
34
|
+
];
|
|
35
|
+
for (const [k, v] of Object.entries(env)) args.push('-e', `${k}=${v}`);
|
|
36
|
+
args.push(image, ...argv);
|
|
37
|
+
return args;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Run one command in a container. Returns the same shape `runInBoundary` does for the fields the runner reads. Never throws. */
|
|
41
|
+
export async function runInContainer({ runtime, image, name, hostRoot, argv, env, limits, timeoutMs, signal, maxOutputBytes, user }) {
|
|
42
|
+
const args = containerArgs({ image, name, hostRoot, argv, env, limits, user });
|
|
43
|
+
const cleanup = () => { try { spawnSync(runtime, ['rm', '-f', name], { timeout: 10_000, killSignal: 'SIGKILL', stdio: 'ignore' }); } catch { /* best effort */ } };
|
|
44
|
+
let r;
|
|
45
|
+
try {
|
|
46
|
+
r = await superviseSpawn(runtime, args, { cwd: hostRoot, // the client needs its own configuration (DOCKER_HOST, contexts, XDG paths); credential-looking names are dropped
|
|
47
|
+
env: scrubEnv(process.env), timeoutMs, signal, maxOutputBytes, graceMs: 1000 });
|
|
48
|
+
} finally { cleanup(); }
|
|
49
|
+
if (r.spawnError) return { blocked: true, reasons: [`could not start the container runtime: ${r.spawnError}`] };
|
|
50
|
+
if (r.cancelled) return { blocked: true, reasons: ['cancelled'] };
|
|
51
|
+
// a runtime failure (image gone, daemon died) is exit 125/126/127 from the client before the target ran: that is not the target's verdict
|
|
52
|
+
if (!r.timedOut && [125, 126, 127].includes(r.exitCode)) return { blocked: true, reasons: [`the container runtime could not run the command (exit ${r.exitCode}): ${String(r.stderr).trim().split('\n').pop()?.slice(0, 160) ?? ''}`] };
|
|
53
|
+
return {
|
|
54
|
+
executed: true, status: 'completed', blocked: false, exitCode: r.exitCode, timedOut: r.timedOut, outputCapped: r.outputCapped,
|
|
55
|
+
targetOutput: { stdout: r.stdout, stderr: r.stderr }, termination: r.termination, backend: 'container',
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -12,7 +12,7 @@ import { proveFinding } from './orchestrator.js';
|
|
|
12
12
|
import { runAttempt } from './runner.js';
|
|
13
13
|
import { reproducerDigest, sha256, canonicalize } from './reproducer.js';
|
|
14
14
|
|
|
15
|
-
export async function verifyFixWithProof({ finding, reproducer, files, scanRoot, revision = null, policy = {}, staticVerification = null, siblings = null, signal, deps = {} } = {}) {
|
|
15
|
+
export async function verifyFixWithProof({ finding, reproducer, files, scanRoot, revision = null, policy = {}, staticVerification = null, siblings = null, siblingSearchTruncated = [], signal, deps = {} } = {}) {
|
|
16
16
|
const t0 = Date.now();
|
|
17
17
|
const attemptFn = deps.attemptFn ?? runAttempt;
|
|
18
18
|
const patchHash = sha256(canonicalize(files || {}));
|
|
@@ -52,6 +52,7 @@ export async function verifyFixWithProof({ finding, reproducer, files, scanRoot,
|
|
|
52
52
|
const siblingCount = Array.isArray(siblings) ? siblings.length : null;
|
|
53
53
|
if (siblingCount === null) degraded.push('sibling-search-not-run');
|
|
54
54
|
else if (siblingCount > 0) degraded.push(`sibling-exposures:${siblingCount}`);
|
|
55
|
+
if (siblingCount !== null && siblingSearchTruncated?.length) degraded.push('sibling-search-incomplete');
|
|
55
56
|
|
|
56
57
|
const full = degraded.length === 0;
|
|
57
58
|
// Same split as fix-verify: `ok` (nothing that ran failed) earns FIX_VERIFIED; `completeness` says whether anything was skipped.
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Reproducer generation for Python (PRD 1), the second language of `generate.js`. Same contract: a DETERMINISTIC proposal built
|
|
2
|
+
// from the flagged file alone, then run through the ordinary runner, oracle, 3-of-3 repetition and independent replay. A generated
|
|
3
|
+
// reproducer is a proposal that execution either confirms or does not.
|
|
4
|
+
//
|
|
5
|
+
// Python is parsed by Python itself (`python3 -I -c`, the standard `ast` module, source on stdin, JSON on stdout) with a timeout, so
|
|
6
|
+
// the shape rules follow the real grammar. Two shapes, as in JavaScript:
|
|
7
|
+
// script the file reads `sys.argv[1]`: it is run with the payload as its argument (whether the flagged line is reached is then
|
|
8
|
+
// decided by execution, not assumed);
|
|
9
|
+
// function a module-level `def` around the flagged line: a generated harness imports the file (never as `__main__`, so its main
|
|
10
|
+
// guard does not run) and calls the function with the payload in every parameter.
|
|
11
|
+
// Only the standard library and sibling `.py` files in the flagged file's directory can be satisfied; any other import is refused,
|
|
12
|
+
// with the package named. Anything outside returns `{ ok: false }` with the reason, and nothing is executed.
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { spawnSync } from 'node:child_process';
|
|
16
|
+
import { validateReproducer } from './reproducer.js';
|
|
17
|
+
|
|
18
|
+
const MAX_SOURCE = 256 * 1024;
|
|
19
|
+
const MAX_FILES = 8;
|
|
20
|
+
const fail = (reason, extra = {}) => ({ ok: false, reason, ...extra });
|
|
21
|
+
|
|
22
|
+
const ANALYZER = `
|
|
23
|
+
import ast, json, sys
|
|
24
|
+
src = sys.stdin.read()
|
|
25
|
+
try:
|
|
26
|
+
tree = ast.parse(src)
|
|
27
|
+
except SyntaxError as e:
|
|
28
|
+
print(json.dumps({"error": "syntax"})); sys.exit(0)
|
|
29
|
+
line = int(sys.argv[1])
|
|
30
|
+
imports = []
|
|
31
|
+
for n in ast.walk(tree):
|
|
32
|
+
if isinstance(n, ast.Import):
|
|
33
|
+
for a in n.names: imports.append({"module": a.name, "level": 0})
|
|
34
|
+
elif isinstance(n, ast.ImportFrom):
|
|
35
|
+
imports.append({"module": n.module or "", "level": n.level or 0, "names": [a.name for a in n.names]})
|
|
36
|
+
fns = []
|
|
37
|
+
for n in ast.walk(tree):
|
|
38
|
+
if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef)) and n.lineno <= line <= (n.end_lineno or n.lineno):
|
|
39
|
+
args = [a.arg for a in n.args.posonlyargs + n.args.args + n.args.kwonlyargs]
|
|
40
|
+
fns.append({"name": n.name, "start": n.lineno, "end": n.end_lineno, "args": args, "async": isinstance(n, ast.AsyncFunctionDef), "decorated": bool(n.decorator_list), "toplevel": n in tree.body, "defaults": len(n.args.defaults)})
|
|
41
|
+
fns.sort(key=lambda f: f["end"] - f["start"])
|
|
42
|
+
main_guard = any(isinstance(n, ast.If) and "__name__" in ast.dump(n.test) for n in tree.body)
|
|
43
|
+
print(json.dumps({"imports": imports, "functions": fns, "mainGuard": main_guard, "stdlib": sorted(sys.stdlib_module_names) if hasattr(sys, "stdlib_module_names") else None}))
|
|
44
|
+
`;
|
|
45
|
+
|
|
46
|
+
function analyze(text, line) {
|
|
47
|
+
const r = spawnSync('python3', ['-I', '-c', ANALYZER, String(line)], { input: text, encoding: 'utf8', timeout: 10_000, maxBuffer: 4 * 1024 * 1024 });
|
|
48
|
+
if (r.error || r.status !== 0) return null;
|
|
49
|
+
try { return JSON.parse(r.stdout); } catch { return null; }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function readInside(root, rel) {
|
|
53
|
+
let real; try { real = fs.realpathSync(path.resolve(root, rel)); } catch { return null; }
|
|
54
|
+
const base = fs.realpathSync(root);
|
|
55
|
+
if (!(real === base || real.startsWith(base + path.sep))) return null;
|
|
56
|
+
const st = fs.statSync(real);
|
|
57
|
+
return st.isFile() && st.size <= MAX_SOURCE ? { real, text: fs.readFileSync(real, 'utf8') } : null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const HARNESS = (entry, name, args) => `# Generated by the proof fabric from the finding's own evidence. It imports the flagged module (never as __main__) and calls the function.
|
|
61
|
+
import importlib.util, sys, asyncio, inspect
|
|
62
|
+
payload = sys.argv[1] if len(sys.argv) > 1 else ""
|
|
63
|
+
spec = importlib.util.spec_from_file_location("target", ${JSON.stringify(entry)})
|
|
64
|
+
mod = importlib.util.module_from_spec(spec)
|
|
65
|
+
sys.modules["target"] = mod
|
|
66
|
+
spec.loader.exec_module(mod)
|
|
67
|
+
fn = getattr(mod, ${JSON.stringify(name)}, None)
|
|
68
|
+
if not callable(fn):
|
|
69
|
+
print("the function is not defined at module level", file=sys.stderr); sys.exit(3)
|
|
70
|
+
try:
|
|
71
|
+
res = fn(${args.join(', ')})
|
|
72
|
+
if inspect.isawaitable(res): asyncio.run(asyncio.wait_for(res, 5))
|
|
73
|
+
except Exception as e:
|
|
74
|
+
print("target raised:", str(e)[:200], file=sys.stderr)
|
|
75
|
+
`;
|
|
76
|
+
|
|
77
|
+
/** @param {{finding: object, root: string, cls: {name: string, payload: string, oracle: object, network?: string, watch?: object, needsPy?: RegExp}}} o */
|
|
78
|
+
export function generatePythonReproducer({ finding, root, cls }) {
|
|
79
|
+
const rel = String(finding.file);
|
|
80
|
+
const src = readInside(root, rel);
|
|
81
|
+
if (!src) return fail('the flagged file is missing, outside the repository, or too large');
|
|
82
|
+
const info = analyze(src.text, finding.line);
|
|
83
|
+
if (!info || info.error) return fail('the flagged file could not be parsed by Python (is python3 installed and is the file valid Python 3?)');
|
|
84
|
+
const lines = src.text.split('\n');
|
|
85
|
+
const sinkLine = lines[finding.line - 1] ?? '';
|
|
86
|
+
const around = lines.slice(Math.max(0, finding.line - 3), finding.line + 2).join('\n');
|
|
87
|
+
if (cls.needsPy && !cls.needsPy.test(sinkLine) && !cls.needsPy.test(around)) return fail(`${cls.name}: the flagged code is not a write, and this class is only proven for write sinks`);
|
|
88
|
+
|
|
89
|
+
const stdlib = new Set(info.stdlib ?? []);
|
|
90
|
+
if (!info.stdlib) return fail('this Python is too old to list its standard library (3.10 or newer is needed), so imports cannot be classified');
|
|
91
|
+
const dir = path.dirname(src.real); const rootReal = fs.realpathSync(root);
|
|
92
|
+
const sources = {}; const external = [];
|
|
93
|
+
for (const imp of info.imports) {
|
|
94
|
+
const top = imp.module.split('.')[0];
|
|
95
|
+
if (imp.level === 0 && stdlib.has(top)) continue;
|
|
96
|
+
const sibling = imp.level <= 1 ? path.join(dir, `${top || (imp.names?.[0] ?? '')}.py`) : null;
|
|
97
|
+
if (sibling && fs.existsSync(sibling)) {
|
|
98
|
+
const dep = readInside(root, path.relative(rootReal, fs.realpathSync(sibling)));
|
|
99
|
+
if (!dep) return fail(`"${top}" is too large or outside the repository`);
|
|
100
|
+
const destRel = path.relative(dir, dep.real);
|
|
101
|
+
if (!sources[destRel]) { if (Object.keys(sources).length >= MAX_FILES) return fail(`the file pulls in more than ${MAX_FILES} repository files`); sources[destRel] = path.relative(rootReal, dep.real); }
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
external.push(top || '.'.repeat(imp.level));
|
|
105
|
+
}
|
|
106
|
+
if (external.length) return fail(`the flagged code needs ${[...new Set(external)].slice(0, 4).join(', ')}, which is not available in the proof environment; only the standard library and sibling files in the same directory can be satisfied`);
|
|
107
|
+
|
|
108
|
+
const entry = 'target.py';
|
|
109
|
+
sources[entry] = rel;
|
|
110
|
+
const assumptions = [];
|
|
111
|
+
const base = { schema: 'agentic-security/reproducer', version: 1, findingId: String(finding.stableId ?? finding.id ?? 'generated'), lang: 'python', oracle: cls.oracle, network: cls.network ?? 'none', ...(cls.watch ? { watch: cls.watch } : {}) };
|
|
112
|
+
if (cls.watch) assumptions.push('the write target is assumed to be one directory below the working directory (the payload is ../escaped.txt); if the code uses another base, the proof will not confirm it');
|
|
113
|
+
|
|
114
|
+
if (/sys\.argv\s*\[\s*1\s*\]/.test(src.text)) {
|
|
115
|
+
const reproducer = { ...base, sources, run: ['{{PYTHON}}', entry, cls.payload] };
|
|
116
|
+
const errs = validateReproducer(reproducer);
|
|
117
|
+
return errs.length ? fail(`the generated reproducer is invalid: ${errs[0]}`) : { ok: true, reproducer, basis: { shape: 'script', class: cls.name, entry: rel, line: finding.line, assumptions } };
|
|
118
|
+
}
|
|
119
|
+
const fn = info.functions.find((f) => f.toplevel);
|
|
120
|
+
if (!fn) return fail('the flagged line is not inside a module-level function, and the file does not read sys.argv[1]');
|
|
121
|
+
if (fn.decorated) return fail(`${fn.name} is decorated (a web framework route, a cache, a wrapper): calling it directly would not match how it is reached, and the framework is not available`);
|
|
122
|
+
if (!fn.args.length) return fail('the function around the flagged line takes no parameters, so the payload has no way in');
|
|
123
|
+
if (fn.async) assumptions.push('the function is a coroutine; the harness awaits it for at most 5 seconds');
|
|
124
|
+
assumptions.push('the payload is passed in every parameter (a Python parameter has no request-like stand-in), which is an approximation of how the function is really called');
|
|
125
|
+
const harness = HARNESS(entry, fn.name, fn.args.map(() => 'payload'));
|
|
126
|
+
const reproducer = { ...base, sources, files: { 'harness.py': harness }, run: ['{{PYTHON}}', 'harness.py', cls.payload] };
|
|
127
|
+
const errs = validateReproducer(reproducer);
|
|
128
|
+
return errs.length ? fail(`the generated reproducer is invalid: ${errs[0]}`) : { ok: true, reproducer, basis: { shape: 'function', class: cls.name, entry: rel, line: finding.line, function: `${fn.name}(${fn.args.join(', ')})`, assumptions } };
|
|
129
|
+
}
|