@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,132 @@
|
|
|
1
|
+
// proof-tools.js: the MCP surface for execution-backed exploit proof (PRD 1).
|
|
2
|
+
//
|
|
3
|
+
// Two tools, both of which RUN CODE in the proof fabric's confined environment, so both are classified `mutating` and both require
|
|
4
|
+
// `confirm: true`:
|
|
5
|
+
// prove_finding execute a reproducer (supplied, stored, or generated from the finding) and report the evidence state
|
|
6
|
+
// prove_fix overlay a patch on a COPY of the sources, replay the original exploit against it, run the static verification,
|
|
7
|
+
// and search for sibling exposures; the patch is never written to the project
|
|
8
|
+
//
|
|
9
|
+
// Guarantees, each with a test:
|
|
10
|
+
// - nothing runs without `confirm: true`, without a verified (HMAC-checked) last scan, or for a shadow finding;
|
|
11
|
+
// - a reproducer an agent supplies is validated as untrusted input (closed schema, argv only, bounded files, safe paths) before
|
|
12
|
+
// it reaches the runner, and it runs under the same sandbox, limits, network policy and 3-of-3 plus independent replay as any other;
|
|
13
|
+
// - patch paths are confined to the session root and the reserved write paths are refused, though nothing is written;
|
|
14
|
+
// - the result carries evidence STATE and counts, never the target's output, and is scrubbed before it is returned;
|
|
15
|
+
// - a model decides nothing: the oracle does.
|
|
16
|
+
// The factory shape (rather than importing helpers from tools.js) avoids a cycle between this file and the registry.
|
|
17
|
+
import { proveFinding } from '../posture/proof-fabric/orchestrator.js';
|
|
18
|
+
import { verifyFixWithProof, formatFixProof } from '../posture/proof-fabric/fix-proof.js';
|
|
19
|
+
import { resolveReproducer } from '../posture/proof-fabric/cli.js';
|
|
20
|
+
import { saveProof, scrubDeep } from '../posture/proof-fabric/store.js';
|
|
21
|
+
import { validateReproducer } from '../posture/proof-fabric/reproducer.js';
|
|
22
|
+
import { findSiblings } from '../posture/proof-fabric/siblings.js';
|
|
23
|
+
import { verifyFix } from '../posture/fix-verify.js';
|
|
24
|
+
|
|
25
|
+
const MAX_RESPONSE_BYTES = 200_000;
|
|
26
|
+
|
|
27
|
+
export function makeProofTools({ confine, META, readLastScanVerified, findById, isReservedWritePath, redactString }) {
|
|
28
|
+
const refuse = (reason, extra = {}) => ({ _meta: META, ok: false, reason, ...extra });
|
|
29
|
+
|
|
30
|
+
async function load(args, ctx) {
|
|
31
|
+
if (args.confirm !== true) return { error: refuse('this tool executes code in the proof environment and requires confirm: true.') };
|
|
32
|
+
const { scan, status } = readLastScanVerified(ctx.sessionRoot, { allowUnsigned: false });
|
|
33
|
+
if (!scan) return { error: refuse(`last-scan.json failed integrity check: ${status}. Run a fresh scan.`) };
|
|
34
|
+
const finding = findById(scan, args.finding_id);
|
|
35
|
+
if (!finding) return { error: refuse(`Finding not found: ${args.finding_id}`) };
|
|
36
|
+
if (finding._shadow === true) return { error: refuse('shadow findings cannot be proven') };
|
|
37
|
+
let reproducer = null; let generated = null;
|
|
38
|
+
if (args.reproducer) {
|
|
39
|
+
const errs = validateReproducer(args.reproducer);
|
|
40
|
+
if (errs.length) return { error: refuse(`the reproducer is not acceptable: ${errs.slice(0, 5).join('; ')}`) };
|
|
41
|
+
reproducer = args.reproducer;
|
|
42
|
+
} else {
|
|
43
|
+
const r = resolveReproducer(ctx.sessionRoot, finding, null);
|
|
44
|
+
if (!r.reproducer) return { error: refuse(`no reproducer was supplied or stored, and none could be generated: ${r.reason}`) };
|
|
45
|
+
reproducer = r.reproducer; generated = r.generated;
|
|
46
|
+
}
|
|
47
|
+
return { scan, finding, reproducer, generated };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const finish = (obj) => {
|
|
51
|
+
const safe = scrubDeep(obj);
|
|
52
|
+
const text = redactString(JSON.stringify(safe));
|
|
53
|
+
if (text.length > MAX_RESPONSE_BYTES) return refuse('the result is too large for one response');
|
|
54
|
+
return { _meta: META, ...JSON.parse(text) };
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const prove_finding = {
|
|
58
|
+
name: 'prove_finding',
|
|
59
|
+
description: 'Execute a reproducer for a finding in the confined proof environment and report its evidence state (CANDIDATE ... EXPLOIT_CONFIRMED). The reproducer is supplied, stored, or generated from the finding; EXPLOIT_CONFIRMED needs 3 of 3 reproductions and an independent replay, decided by a deterministic oracle, never by a model. Runs code: requires confirm: true. Returns state and counts, never the target\'s output.',
|
|
60
|
+
inputSchema: {
|
|
61
|
+
type: 'object',
|
|
62
|
+
additionalProperties: false,
|
|
63
|
+
properties: {
|
|
64
|
+
finding_id: { type: 'string', minLength: 1, maxLength: 256 },
|
|
65
|
+
confirm: { type: 'boolean' },
|
|
66
|
+
reproducer: { type: 'object' },
|
|
67
|
+
attempts: { type: 'integer', minimum: 1, maximum: 10 },
|
|
68
|
+
required: { type: 'integer', minimum: 1, maximum: 10 },
|
|
69
|
+
require_container: { type: 'boolean' },
|
|
70
|
+
},
|
|
71
|
+
required: ['finding_id', 'confirm'],
|
|
72
|
+
},
|
|
73
|
+
async handler(args, ctx) {
|
|
74
|
+
const l = await load(args, ctx);
|
|
75
|
+
if (l.error) return l.error;
|
|
76
|
+
const policy = {};
|
|
77
|
+
if (args.attempts) policy.attempts = args.attempts;
|
|
78
|
+
if (args.required) policy.required = Math.min(args.required, args.attempts ?? 10);
|
|
79
|
+
if (args.require_container) policy.requireContainer = true;
|
|
80
|
+
let result;
|
|
81
|
+
try { result = await proveFinding({ finding: l.finding, reproducer: l.reproducer, policy, sourceRoot: ctx.sessionRoot }); } catch (e) { return refuse(`the proof could not run: ${String(e.message).slice(0, 200)}`); }
|
|
82
|
+
const saved = saveProof(ctx.sessionRoot, { ...result, reproducer: l.reproducer, ...(l.generated ? { generatedFrom: l.generated } : {}) });
|
|
83
|
+
return finish({
|
|
84
|
+
ok: true, findingId: result.findingId, state: result.state, outcome: result.outcome, oracle: result.oracle, completeness: result.completeness,
|
|
85
|
+
attempts: result.attempts, independentReplay: result.independentReplay?.status ?? 'not-run', environment: result.environment ? { mode: result.environment.mode, assurance: result.environment.assurance } : null,
|
|
86
|
+
degradation: result.degradation, durationMs: result.timing?.proofMs ?? null, generated: l.generated, stored: saved.ok,
|
|
87
|
+
note: 'Not reproduced is not proof of safety.',
|
|
88
|
+
});
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const prove_fix = {
|
|
93
|
+
name: 'prove_fix',
|
|
94
|
+
description: 'Check a proposed patch against the original exploit: confirm the exploit before the patch, overlay the patch on a COPY of the sources, replay the exploit, run the static verification, and search for sibling exposures. Reports FIX_VERIFIED only when the exploit is blocked in every replay and nothing was skipped. Nothing is written to the project. Runs code (and the project tests): requires confirm: true.',
|
|
95
|
+
inputSchema: {
|
|
96
|
+
type: 'object',
|
|
97
|
+
additionalProperties: false,
|
|
98
|
+
properties: {
|
|
99
|
+
finding_id: { type: 'string', minLength: 1, maxLength: 256 },
|
|
100
|
+
confirm: { type: 'boolean' },
|
|
101
|
+
patch: { type: 'object', additionalProperties: { type: 'string', maxLength: 500_000 }, minProperties: 1, maxProperties: 8 },
|
|
102
|
+
reproducer: { type: 'object' },
|
|
103
|
+
},
|
|
104
|
+
required: ['finding_id', 'confirm', 'patch'],
|
|
105
|
+
},
|
|
106
|
+
async handler(args, ctx) {
|
|
107
|
+
const l = await load(args, ctx);
|
|
108
|
+
if (l.error) return l.error;
|
|
109
|
+
const files = {};
|
|
110
|
+
for (const [rel, content] of Object.entries(args.patch)) {
|
|
111
|
+
let abs;
|
|
112
|
+
try { abs = confine(ctx.sessionRoot, rel, 'patch key'); } catch (e) { return refuse(`path-escape refused: ${String(e.message).replace(ctx.sessionRoot, '<root>')}`); }
|
|
113
|
+
if (isReservedWritePath(ctx.sessionRoot, abs)) return refuse(`reserved path refused: ${rel}`);
|
|
114
|
+
files[rel] = String(content);
|
|
115
|
+
}
|
|
116
|
+
let staticVerification = null;
|
|
117
|
+
try { staticVerification = l.finding.stableId ? await verifyFix({ scanRoot: ctx.sessionRoot, originalFindingStableId: l.finding.stableId, files, recordMetrics: false }) : null; } catch { staticVerification = null; }
|
|
118
|
+
const sib = findSiblings({ root: ctx.sessionRoot, finding: l.finding, scanFindings: l.scan.findings || [] });
|
|
119
|
+
let fp;
|
|
120
|
+
try {
|
|
121
|
+
fp = await verifyFixWithProof({ finding: l.finding, reproducer: l.reproducer, files, scanRoot: ctx.sessionRoot, staticVerification, siblings: sib.siblings.map((x) => ({ file: x.file, line: x.line, source: x.source })), siblingSearchTruncated: sib.truncated });
|
|
122
|
+
} catch (e) { return refuse(`the fix proof could not run: ${String(e.message).slice(0, 200)}`); }
|
|
123
|
+
return finish({
|
|
124
|
+
ok: true, findingId: fp.findingId, state: fp.state, verified: fp.verified, completeness: fp.completeness, degradedLegs: fp.degradedLegs ?? [], legs: fp.legs,
|
|
125
|
+
reason: fp.reason ?? null, incompleteStep: fp.incompleteStep ?? null, siblings: sib.siblings.slice(0, 20), siblingSearchTruncated: sib.truncated, report: formatFixProof(fp), generated: l.generated,
|
|
126
|
+
note: 'The patch was not written. Apply it with apply_fix once it is verified.',
|
|
127
|
+
});
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
return { prove_finding, prove_fix };
|
|
132
|
+
}
|
package/src/mcp/server.js
CHANGED
|
@@ -45,7 +45,7 @@ const TOOLS_BY_NAME = Object.fromEntries(ALL_TOOLS.map(t => [t.name, t]));
|
|
|
45
45
|
function _codeFingerprint() {
|
|
46
46
|
try {
|
|
47
47
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
48
|
-
const files = ['server.js', 'tools.js', 'dataflow-tools.js', 'invariant-tools.js', 'portfolio-tools.js', 'stdio.js', 'audit.js', 'validate.js', 'redact.js'];
|
|
48
|
+
const files = ['server.js', 'tools.js', 'dataflow-tools.js', 'invariant-tools.js', 'portfolio-tools.js', 'proof-tools.js', 'stdio.js', 'audit.js', 'validate.js', 'redact.js'];
|
|
49
49
|
const h = crypto.createHash('sha256');
|
|
50
50
|
for (const f of files) {
|
|
51
51
|
try { h.update(f); h.update(fs.readFileSync(path.join(here, f))); } catch {}
|
package/src/mcp/tools.js
CHANGED
|
@@ -37,6 +37,7 @@ import { redactFindingProvenance } from '../posture/provenance/schema.js';
|
|
|
37
37
|
import { dataflow_get_graph, dataflow_get_node, dataflow_get_edge, dataflow_get_flow } from './dataflow-tools.js';
|
|
38
38
|
import { makeInvariantTools } from './invariant-tools.js';
|
|
39
39
|
import { makePortfolioTools } from './portfolio-tools.js';
|
|
40
|
+
import { makeProofTools } from './proof-tools.js';
|
|
40
41
|
|
|
41
42
|
// Lazy-loaded: these transitively pull in npm packages (@babel/core and
|
|
42
43
|
// friends) that aren't available in the plugin-cache install path
|
|
@@ -1701,6 +1702,7 @@ export const apply_sca_upgrade = {
|
|
|
1701
1702
|
// X-407: read-only scenario export; the factory takes this file's confinement so the two modules do not import each other.
|
|
1702
1703
|
const { invariant_scenario_export } = makeInvariantTools({ confine: _confine, META });
|
|
1703
1704
|
// X-707: read-only portfolio progress view, same factory shape.
|
|
1705
|
+
const { prove_finding, prove_fix } = makeProofTools({ confine: _confine, META, readLastScanVerified: _readLastScanVerified, findById: _findById, isReservedWritePath: _isReservedWritePath, redactString });
|
|
1704
1706
|
const { portfolio_progress } = makePortfolioTools({ confine: _confine, META });
|
|
1705
1707
|
|
|
1706
|
-
export const ALL_TOOLS = [scan_diff, query_taint, explain_finding, apply_fix, verify_fix, synthesize_fix, find_rule_module, append_scratchpad, read_scratchpad, append_agents_memory, read_agents_memory, lookup_cve, synthesize_sca_upgrade, apply_sca_upgrade, query_triage_memory, query_findings_memory, query_cache_telemetry, dataflow_get_graph, dataflow_get_node, dataflow_get_edge, dataflow_get_flow, invariant_scenario_export, portfolio_progress];
|
|
1708
|
+
export const ALL_TOOLS = [scan_diff, query_taint, explain_finding, apply_fix, verify_fix, synthesize_fix, find_rule_module, append_scratchpad, read_scratchpad, append_agents_memory, read_agents_memory, lookup_cve, synthesize_sca_upgrade, apply_sca_upgrade, query_triage_memory, query_findings_memory, query_cache_telemetry, dataflow_get_graph, dataflow_get_node, dataflow_get_edge, dataflow_get_flow, invariant_scenario_export, portfolio_progress, prove_finding, prove_fix];
|
package/src/posture/CLAUDE.md
CHANGED
|
@@ -996,6 +996,29 @@ reading the cache; it does not defeat root or the same OS user. See
|
|
|
996
996
|
`cache.js`'s own header for the full tradeoff writeup, including why
|
|
997
997
|
encryption-at-rest was considered and deferred.
|
|
998
998
|
|
|
999
|
+
## Detection and response: `dnr/` (PRD 7)
|
|
1000
|
+
|
|
1001
|
+
Turns a PROVEN finding into a validated detection, hunts local historical logs for the exploit's shape, scopes the observable exposure window, and shows Find, Prove, Hunt, Fix, Verify and Detect for one finding. Entry point `dnr/cli.js` (`cmdDnr`); guide `docs/guides/detection-and-response.md`; tests `test/dnr/`, `test/cli/dnr-cli.test.js`.
|
|
1002
|
+
|
|
1003
|
+
* **No network, no model.** Nothing in `dnr/` imports an HTTP module or the LLM/egress layers (a test pins it). Production logs stay on the machine, so the model-egress policy has nothing to gate here. Do not add a model call to "improve" a detection: the point is that every fact has a source.
|
|
1004
|
+
* **The IR is closed-world** (`detection-ir.js`, `attack-facts.js`). There is no field for an attacker address, domain, file hash or user agent, and `validateDetectionIR` refuses unknown keys. Request facts come from the optional `attack` block on the reproducer, which the proof runner never reads. A missing fact is `null` plus a stated limitation, never a guess.
|
|
1005
|
+
* **One evaluator** (`matchers.js` `evaluateEvent`) serves validation, hunting and the descriptions of the rendered rules. A rule that validates is the rule that searches. Keep it that way: a second matcher would let a tested rule and a shipped rule drift apart.
|
|
1006
|
+
* **A negative needs evidence** (`hunt.js`). `NOT_OBSERVED` is returned only when the logs parsed cleanly, carried every field the detection needs, were read to the end, and had timestamps. Anything less is `INCONCLUSIVE`. `notExploitedClaim` is always `false`. The exit code per verdict differs so automation cannot read an unanswered question as clean.
|
|
1007
|
+
* **Bounded reads** (`log-reader.js`): file count, depth, file size, total size, line length, line count, wall clock, abort signal; symlinks and compressed files are skipped and reported. Every limit hit is disclosed and blocks a `NOT_OBSERVED`.
|
|
1008
|
+
* **Redaction before persistence or display** (`redact.js`), on top of the proof store's secret scrubber. Records live under `.agentic-security/dnr/` (registered in `artifact-registry.js`, evidence class, encrypted by policy, tamper-evident).
|
|
1009
|
+
* **Names:** a detection is not a finding rule. `rule-compiler/` promotes DETECTION rules for the scanner; `dnr/` produces RESPONSE rules for a SIEM, a WAF or a log search. Do not merge them.
|
|
1010
|
+
|
|
1011
|
+
## Eval Lab and the policy router: `evaluation/lab/`, `routing/policy-router.js` (PRD 6)
|
|
1012
|
+
|
|
1013
|
+
Measures a deterministic baseline and model arms, and routes tasks from the measurements. Entry points `evaluation/lab/cli.js` (`cmdEval`, `cmdModelsLab`) and `routing/policy-router.js`; guide `docs/guides/eval-lab.md`; tests `test/eval-lab/`, `test/cli/eval-lab-cli.test.js`.
|
|
1014
|
+
|
|
1015
|
+
* **Lives inside `evaluation/` on purpose.** It reuses the custody audit (`auditWorkspace`, `protectedTermsFrom`, `guardPrompt`) and `defaultScanFn`. The isolation test keeps the custody, label, runner, score, gates and protocol modules unreachable from outside `evaluation/`, so the lab's CLI is the only door and it imports nothing the engine imports back. Do not move the lab out to "tidy" it.
|
|
1016
|
+
* **Mechanical evidence decides.** The lab never believes an arm's claim that a PoC works or a patch holds: it executes the reproducer (`proveFinding`) and replays the exploit against the patch (`verifyFixWithProof`). An attempt that does not complete is recorded and scored as a no-result (a miss for a positive, `unscored` for a negative), never dropped.
|
|
1017
|
+
* **Labels never reach an arm.** Labels live only in the manifest; workspaces are staged under neutral names, neutralized (`neutralize.js`) and audited, and every request is guarded. A hit quarantines the entry and marks the run incomplete. Dataset `leakagePolicy.mode` accepts only `enforce`.
|
|
1018
|
+
* **No provider client.** Models attach through the `command` adapter (request in a file, answer on stdout, scrubbed env, timeout, whole-tree cleanup). A non-local arm is checked against `evaluateEgress` before it runs and there is no fallback between arms. Do not add a direct HTTP client here.
|
|
1019
|
+
* **The router is the policy layer, `routing/decide.js` is the calibrated one.** `policy-router.js` selects from measured scoreboard entries (quality, median cost, median latency, run count) under four policies; `decide.js` keeps quality LOWER bounds on held-out outcomes per stratum. They do not call each other. The router refuses to guess: too few runs, unknown cost under a cost policy, an egress denial or a dataset mismatch excludes a model with the reason, and `threat-model` is blocked because it has no evaluation task.
|
|
1020
|
+
* **Digest-checked state.** Runs and the scoreboard live under `.agentic-security/eval-lab/` (registered in `artifact-registry.js`); a record that no longer matches its digest is reported and never used for routing.
|
|
1021
|
+
|
|
999
1022
|
## Gotchas
|
|
1000
1023
|
|
|
1001
1024
|
- The seed `calibration-seed.json` is small (n < 30 for several families). Don't treat it as a held-out set — that's `holdout-eval.js`'s job, against an externally-supplied JSONL.
|
|
@@ -133,6 +133,7 @@ export const ARTIFACT_REGISTRY = [
|
|
|
133
133
|
{ name: 'proofs', kind: 'dir', classification: 'generated', retentionClass: 'evidence', confidential: true, source: 'src/posture/proof-fabric/store.js (saveProof)', note: 'Universal Exploit Proof Fabric evidence: hashed records of exploit reproductions. Never holds plaintext credentials, per-run nonces or raw secrets (the runner redacts before persisting). Encrypted per the encryption policy via maybeEncryptForWrite, and purged by retention like any evidence-class artifact; a legal hold on `proofs` blocks the purge.' },
|
|
134
134
|
{ name: 'fleet-state.json', kind: 'file', classification: 'generated', retentionClass: 'scan', source: 'src/posture/fleet/cli.js, src/posture/fleet.js (loadFleetState/_saveFleetState)', note: 'Per-repository resume checkpoint for `fleet scan` — which fleet repositories are already complete, so a resumed run skips them. Scanner-managed; a plain reset may delete it.' },
|
|
135
135
|
{ name: 'fleet-rollup.json', kind: 'file', classification: 'generated', retentionClass: 'scan', source: 'src/posture/fleet/cli.js', note: 'The last fleet rollup (rollupFleet output), kept only so the NEXT `fleet scan` can compute a delta against it and so `fleet status` has something to read without re-scanning.' },
|
|
136
|
+
{ name: 'fleet', kind: 'dir', classification: 'generated', retentionClass: 'scan', source: 'src/posture/fleet/cli.js, src/posture/fleet/job-store.js, src/posture/fleet/job-runner.js, src/posture/fleet/scan-cache.js', note: 'Distributed scan fabric state: the SQLite job store (fleet.db with its WAL files: runs, jobs, leases, heartbeats, retry counts, result hashes), per-run scan artifacts (a slim copy of each repository\'s findings, verified against the recorded hash before use), the content-addressed scan cache (reused only when commit, lockfiles, configuration and scanner build are unchanged) and the stored enterprise graph with the evidence for every edge. Holds finding locations, never source content or secrets. Safe to delete: the next scan rebuilds it, at the cost of rescanning.' },
|
|
136
137
|
{ name: 'fleet-graph.json', kind: 'file', classification: 'generated', retentionClass: 'scan', source: 'src/posture/fleet/cli.js, src/posture/fleet/fleet-graph.js', note: 'The cross-repository dependency graph built from each fleet repository\'s own manifest, consumed by `fleet query --depends-on`. Rebuilt on every `fleet scan`.' },
|
|
137
138
|
{ name: 'mcp-audit.log', kind: 'file', classification: 'generated', retentionClass: 'evidence' },
|
|
138
139
|
{ name: 'egress-audit.log', kind: 'file', classification: 'generated', retentionClass: 'evidence', note: "FR-604 per-call egress audit log — hash-chained NDJSON written by egress/audit.js's recordEgressCall, never read as config" },
|
|
@@ -240,6 +241,9 @@ export const ARTIFACT_REGISTRY = [
|
|
|
240
241
|
{ name: 'rules.yml', kind: 'file', classification: 'operator-config' },
|
|
241
242
|
{ name: 'rules', kind: 'dir', classification: 'operator-config' },
|
|
242
243
|
{ name: 'rules-proposed', kind: 'dir', classification: 'operator-config', note: 'proposed rules awaiting human review — not yet approved config, but not scanner-regenerable either' },
|
|
244
|
+
{ name: 'eval-lab', kind: 'dir', classification: 'generated', retentionClass: 'evidence', confidential: true, source: 'src/posture/evaluation/lab/store.js (saveRun, saveScoreboard)', note: 'Eval Lab: digest-chained run records (per-attempt outcomes, scores, leakage-audit result, machine profile) and the model scoreboard the policy router reads. Runs keep digests of model patches, never their contents; secrets are scrubbed before writing. A run or scoreboard that no longer matches its own digest is reported as failed and never used for routing. Encrypted per the encryption policy and purged by retention like other evidence-class artifacts; a legal hold on `eval-lab` blocks the purge.' },
|
|
245
|
+
{ name: 'runtime-explorer.html', kind: 'file', classification: 'generated', retentionClass: 'cache', confidential: true, source: 'bin/agentic-security.js (cmdRuntimeExplorer)', note: 'The static runtime-twin page written by `explore . --runtime`: edge states, destinations and counts from imported telemetry, with identities only as keyed-hash tokens. Regenerable from the signed graph and the observation store, so it is a cache; it can reveal internal hostnames and service names, so it is treated as confidential.' },
|
|
246
|
+
{ name: 'dnr', kind: 'dir', classification: 'generated', retentionClass: 'evidence', confidential: true, source: 'src/posture/dnr/store.js (saveRecord)', note: 'Detection-and-response loop: per-finding detection IR with its validation result, historical-hunt results and exposure windows. Hunt samples are redacted and truncated before they are written (a value fingerprint stands in for the value), so production request contents are not stored. Encrypted per the encryption policy and purged by retention like other evidence-class artifacts; a legal hold on `dnr` blocks the purge.' },
|
|
243
247
|
{ name: 'rule-compiler', kind: 'dir', classification: 'generated', retentionClass: 'evidence', source: 'src/posture/rule-compiler/store.js', note: 'Self-Learning Finding-to-Rule Compiler: per-rule lifecycle ledger, IR, evaluation score and regression fixtures. The PROMOTED custom-rule document it publishes lives separately under `rules/` (operator-config, unaffected by reset) once promoted; this directory holds only the scanner-managed provenance and corpus behind that decision.' },
|
|
244
248
|
{ name: 'license-policy.yml', kind: 'file', classification: 'operator-config' },
|
|
245
249
|
{ name: 'trusted-keys.json', kind: 'file', classification: 'operator-config' },
|
|
@@ -23,6 +23,13 @@
|
|
|
23
23
|
// match.allOf: [regex…] — every regex must match somewhere in the file
|
|
24
24
|
// match.notMatch: regex — kill if this regex matches
|
|
25
25
|
// match.window: N — when allOf is set, all matches must fall within N lines
|
|
26
|
+
// match.fileRequires: [regex…]: file-level preconditions (e.g. a framework marker); every one must match somewhere in the file
|
|
27
|
+
// match.guardedBy: regex : a sanitizer or dominating guard: a hit is dropped when this matches within `guardWindow` lines BEFORE
|
|
28
|
+
// (or on) the hit's line. Unlike notMatch it is local to the hit, not the whole file.
|
|
29
|
+
// match.guardWindow: N : how far back guardedBy looks (default: the rule's window, else 5)
|
|
30
|
+
// match.excludeLine: [regex…]: a hit is dropped when its own line matches any of these
|
|
31
|
+
// (the last four are the structural vocabulary the rule compiler's IR compiles to; a rule that uses any of them is evaluated
|
|
32
|
+
// against EVERY anchor and reports the first that survives, not only the first anchor in the file)
|
|
26
33
|
//
|
|
27
34
|
// `agentic-security rule test <fixture-glob>` runs every rule against every
|
|
28
35
|
// file and prints PASS/FAIL.
|
|
@@ -153,6 +160,10 @@ function _isSafePattern(p) {
|
|
|
153
160
|
return !_REDOS_NESTED_RE.test(p);
|
|
154
161
|
}
|
|
155
162
|
|
|
163
|
+
/** The loader's own rule normalizer, exported so anything that scores or validates a rule uses the SAME one the scanner loads
|
|
164
|
+
* with (and so a rule the loader would reject is never reported as working). Returns null for a rule the loader refuses. */
|
|
165
|
+
export function normalizeCustomRule(r, fp = '(compiled)') { return normalizeRule(r, fp); }
|
|
166
|
+
|
|
156
167
|
function normalizeRule(r, fp) {
|
|
157
168
|
if (!r || !r.id || !r.match) return null;
|
|
158
169
|
const m = r.match;
|
|
@@ -177,6 +188,22 @@ function normalizeRule(r, fp) {
|
|
|
177
188
|
let notMatch = null;
|
|
178
189
|
if (m.notMatch) { try { notMatch = new RegExp(m.notMatch, 'm'); } catch {} }
|
|
179
190
|
|
|
191
|
+
// Structural vocabulary (rule compiler IR): every regex passes the same ReDoS guard as the main patterns, and a rule that
|
|
192
|
+
// cannot be compiled is rejected whole rather than loaded with a constraint silently missing.
|
|
193
|
+
const compileAll = (list) => {
|
|
194
|
+
const out = [];
|
|
195
|
+
for (const p of list) {
|
|
196
|
+
if (typeof p !== 'string' || !p || !_isSafePattern(p)) { console.error(`agentic-security: rejected unsafe or non-string structural regex in ${r.id}`); return null; }
|
|
197
|
+
try { out.push(new RegExp(p, 'm')); } catch (e) { console.error(`agentic-security: invalid structural regex in ${r.id}: ${e.message}`); return null; }
|
|
198
|
+
}
|
|
199
|
+
return out;
|
|
200
|
+
};
|
|
201
|
+
let fileRequires = null; let guardedBy = null; let excludeLine = null;
|
|
202
|
+
if (m.fileRequires != null) { if (!Array.isArray(m.fileRequires)) return null; fileRequires = compileAll(m.fileRequires); if (!fileRequires) return null; }
|
|
203
|
+
if (m.guardedBy != null) { const g = compileAll([m.guardedBy]); if (!g) return null; guardedBy = g[0]; }
|
|
204
|
+
if (m.excludeLine != null) { if (!Array.isArray(m.excludeLine)) return null; excludeLine = compileAll(m.excludeLine); if (!excludeLine) return null; }
|
|
205
|
+
const guardWindow = Number.isInteger(m.guardWindow) && m.guardWindow > 0 && m.guardWindow <= 200 ? m.guardWindow : (m.window || 5);
|
|
206
|
+
|
|
180
207
|
// SentQL extensions (Sentinel-parity FR-DSL-1):
|
|
181
208
|
// llm_validate: { prompt, min_confidence }
|
|
182
209
|
// path: { must_traverse: [...], must_not_traverse: [...] }
|
|
@@ -208,6 +235,8 @@ function normalizeRule(r, fp) {
|
|
|
208
235
|
notMatch,
|
|
209
236
|
requireAll: Array.isArray(m.allOf),
|
|
210
237
|
windowLines: m.window || 50,
|
|
238
|
+
fileRequires, guardedBy, guardWindow, excludeLine,
|
|
239
|
+
structural: !!(fileRequires || guardedBy || excludeLine),
|
|
211
240
|
sourceFile: fp,
|
|
212
241
|
llmValidate,
|
|
213
242
|
pathConstraints,
|
|
@@ -260,17 +289,34 @@ export function runRule(rule, file, content) {
|
|
|
260
289
|
});
|
|
261
290
|
|
|
262
291
|
const findings = [];
|
|
292
|
+
// structural preconditions and per-hit filters (see the header): file-level requirements first
|
|
293
|
+
if (rule.fileRequires && !rule.fileRequires.every((rx) => rx.test(content))) return [];
|
|
294
|
+
const lineText = (n) => lines[n - 1] ?? '';
|
|
295
|
+
const guardLines = [];
|
|
296
|
+
if (rule.guardedBy) {
|
|
297
|
+
const g = new RegExp(rule.guardedBy.source, 'gm');
|
|
298
|
+
let gm; const deadline = Date.now() + EXEC_TIMEOUT_MS;
|
|
299
|
+
while ((gm = g.exec(content)) !== null) { guardLines.push(offsetToLine(gm.index)); if (gm.index === g.lastIndex) g.lastIndex++; if (Date.now() > deadline) break; }
|
|
300
|
+
}
|
|
301
|
+
const survives = (hit) => {
|
|
302
|
+
if (rule.excludeLine && rule.excludeLine.some((rx) => rx.test(lineText(hit.line)))) return false;
|
|
303
|
+
if (rule.guardedBy && guardLines.some((gl) => gl <= hit.line && hit.line - gl <= rule.guardWindow)) return false;
|
|
304
|
+
return true;
|
|
305
|
+
};
|
|
263
306
|
if (rule.requireAll) {
|
|
264
307
|
if (matches.some(l => l.length === 0)) return [];
|
|
265
|
-
// Pick the earliest match in the first list, verify the others fall within window.
|
|
266
|
-
const
|
|
267
|
-
const
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
308
|
+
// Pick the earliest match in the first list, verify the others fall within window. A structural rule tries every anchor in order.
|
|
309
|
+
const anchors = rule.structural ? matches[0] : [matches[0][0]];
|
|
310
|
+
for (const anchor of anchors) {
|
|
311
|
+
const close = matches.slice(1).every(list =>
|
|
312
|
+
list.some(x => Math.abs(x.line - anchor.line) <= rule.windowLines)
|
|
313
|
+
);
|
|
314
|
+
if (!close || !survives(anchor)) continue;
|
|
315
|
+
findings.push(toFinding(rule, file, anchor));
|
|
316
|
+
break;
|
|
317
|
+
}
|
|
272
318
|
} else {
|
|
273
|
-
for (const m of matches[0]) findings.push(toFinding(rule, file, m));
|
|
319
|
+
for (const m of matches[0]) if (survives(m)) findings.push(toFinding(rule, file, m));
|
|
274
320
|
}
|
|
275
321
|
return findings;
|
|
276
322
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// The request-level facts a proof can carry about HOW the vulnerable operation is reached: `POST /api/export`, parameter
|
|
2
|
+
// `filename`, sink `exec()`. They are optional and ride on the reproducer (as `reproducer.attack`) because only the person or
|
|
3
|
+
// agent who wrote the reproducer knows them; the runner never reads them, so adding them cannot change a proof's result.
|
|
4
|
+
//
|
|
5
|
+
// Closed-world on purpose. There is no field for an attacker address, a domain, a file hash or a user agent: a detection that
|
|
6
|
+
// is built only from these facts cannot contain an invented indicator, because there is nowhere to put one.
|
|
7
|
+
import { PAYLOAD_CLASS_NAMES } from './payload-classes.js';
|
|
8
|
+
|
|
9
|
+
const ATTACK_FACT_KEYS = Object.freeze(['method', 'path', 'parameter', 'location', 'payloadClass', 'sink']);
|
|
10
|
+
export const HTTP_METHODS = Object.freeze(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']);
|
|
11
|
+
export const PARAM_LOCATIONS = Object.freeze(['query', 'body', 'header', 'path', 'cookie']);
|
|
12
|
+
|
|
13
|
+
const PATH_RE = /^\/[A-Za-z0-9/_\-.:{}~%]{0,199}$/;
|
|
14
|
+
const PARAM_RE = /^[A-Za-z_][A-Za-z0-9_.\-[\]]{0,63}$/;
|
|
15
|
+
const SINK_RE = /^[A-Za-z0-9_.()\- /$]{1,80}$/;
|
|
16
|
+
|
|
17
|
+
/** Returns a list of problems; empty means the facts are acceptable. Unknown keys are refused, never ignored. */
|
|
18
|
+
export function validateAttackFacts(a) {
|
|
19
|
+
if (a == null) return [];
|
|
20
|
+
if (typeof a !== 'object' || Array.isArray(a)) return ['attack must be an object'];
|
|
21
|
+
const errs = [];
|
|
22
|
+
for (const k of Object.keys(a)) if (!ATTACK_FACT_KEYS.includes(k)) errs.push(`attack.${k} is not a recognised fact`);
|
|
23
|
+
if (a.method != null && !HTTP_METHODS.includes(a.method)) errs.push('attack.method must be an HTTP method in upper case');
|
|
24
|
+
if (a.path != null && (typeof a.path !== 'string' || !PATH_RE.test(a.path) || a.path.includes('..'))) errs.push('attack.path must be an absolute URL path without a query string');
|
|
25
|
+
if (a.parameter != null && (typeof a.parameter !== 'string' || !PARAM_RE.test(a.parameter))) errs.push('attack.parameter must be a plain parameter name');
|
|
26
|
+
if (a.location != null && !PARAM_LOCATIONS.includes(a.location)) errs.push(`attack.location must be one of ${PARAM_LOCATIONS.join(', ')}`);
|
|
27
|
+
if (a.payloadClass != null && !PAYLOAD_CLASS_NAMES.includes(a.payloadClass)) errs.push(`attack.payloadClass must be one of ${PAYLOAD_CLASS_NAMES.join(', ')}`);
|
|
28
|
+
if (a.sink != null && (typeof a.sink !== 'string' || !SINK_RE.test(a.sink))) errs.push('attack.sink must be a short operation name');
|
|
29
|
+
if ((a.parameter != null) !== (a.location != null)) errs.push('attack.parameter and attack.location go together');
|
|
30
|
+
return errs;
|
|
31
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// CLI surface for the detection-and-response loop: `dnr generate|validate|hunt|window|show|status`.
|
|
2
|
+
// Non-interactive by construction (no command reads stdin), bounded (log reads have size, line and time budgets), and local:
|
|
3
|
+
// nothing here contacts a network or a model, so production logs never leave the machine.
|
|
4
|
+
//
|
|
5
|
+
// Exit codes: 0 completed and clean, 4 usage or precondition, 1 a stored/produced result failed its own check (validation),
|
|
6
|
+
// and for `hunt` a distinct code per verdict so automation cannot read an unanswered question as a clean one:
|
|
7
|
+
// 0 NOT_OBSERVED 10 OBSERVED 11 INCONCLUSIVE 12 TELEMETRY_UNAVAILABLE
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { latestProof } from '../proof-fabric/store.js';
|
|
11
|
+
import { buildDetectionIR, validateDetectionIR } from './detection-ir.js';
|
|
12
|
+
import { renderDetection, renderAll, FORMATS } from './outputs.js';
|
|
13
|
+
import { validateDetection } from './validate.js';
|
|
14
|
+
import { huntLogs } from './hunt.js';
|
|
15
|
+
import { computeExposureWindow, loadDeployments, formatExposureWindow } from './exposure-window.js';
|
|
16
|
+
import { saveRecord, latestRecord, listRecords } from './store.js';
|
|
17
|
+
import { buildLifecycleView, formatLifecycleView, loadFindingFromLastScan } from './view.js';
|
|
18
|
+
|
|
19
|
+
const HUNT_EXIT = Object.freeze({ NOT_OBSERVED: 0, OBSERVED: 10, INCONCLUSIVE: 11, TELEMETRY_UNAVAILABLE: 12 });
|
|
20
|
+
const EXT = { sigma: 'yml', http: 'json', waf: 'conf', 'log-query': 'json', yara: 'yar', spl: 'spl', kql: 'kql', cloudwatch: 'txt', elastic: 'json', yaral: 'yaral', datadog: 'json' };
|
|
21
|
+
|
|
22
|
+
const usage = () => { console.error('usage: dnr generate <finding-id> [--format sigma|http|waf|log-query|yara|spl|kql|cloudwatch|elastic|yaral|datadog|all] [--out dir] [--allow-unproven]\n dnr validate <finding-id>\n dnr hunt <finding-id> --logs <file-or-dir> [--since ISO] [--until ISO]\n dnr window <finding-id> --deployments <file> [--fix-commit sha] [--environment name]\n dnr show <finding-id>\n dnr status <finding-id>'); return 4; };
|
|
23
|
+
|
|
24
|
+
function ensureDetection(root, id, flags) {
|
|
25
|
+
const existing = latestRecord(root, id, 'detection');
|
|
26
|
+
if (existing && !flags.regenerate) return { ok: true, saved: existing.record };
|
|
27
|
+
const finding = loadFindingFromLastScan(root, id);
|
|
28
|
+
if (!finding) return { ok: false, reason: `Finding ${id} not found in the last scan.` };
|
|
29
|
+
const proofRec = latestProof(root, id);
|
|
30
|
+
const built = buildDetectionIR({ finding, proof: proofRec?.record ?? null, allowUnproven: !!flags['allow-unproven'] });
|
|
31
|
+
if (!built.ok) return { ok: false, reason: built.reason };
|
|
32
|
+
const validation = validateDetection(built.ir);
|
|
33
|
+
const record = { ir: built.ir, validation: { status: validation.status, counts: validation.counts, failures: validation.failures ?? [], reason: validation.reason ?? null }, enabled: validation.status === 'VALIDATED', proof: proofRec?.record ? { recordDigest: proofRec.record.recordDigest ?? null, state: proofRec.record.state ?? null } : null };
|
|
34
|
+
const saved = saveRecord(root, id, 'detection', record);
|
|
35
|
+
if (!saved.ok) return { ok: false, reason: `could not store the detection: ${saved.reason}` };
|
|
36
|
+
return { ok: true, saved: { ...record, recordDigest: saved.digest }, fresh: true };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function writeOut(dir, id, ir, formats) {
|
|
40
|
+
// the id names a file: it must be the digest-derived form, never text that arrived from a record on disk
|
|
41
|
+
if (!/^det-[0-9a-f]{12}$/.test(String(ir.id))) { console.error('refusing to write: the detection id is not in its derived form'); return []; }
|
|
42
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
43
|
+
const written = [];
|
|
44
|
+
for (const f of formats) {
|
|
45
|
+
const r = renderDetection(ir, f);
|
|
46
|
+
if (!r.applicable) continue;
|
|
47
|
+
const file = path.join(dir, `${ir.id}.${f === 'log-query' ? 'query' : f}.${EXT[f]}`);
|
|
48
|
+
try { if (fs.lstatSync(file).isSymbolicLink()) { console.error(`refusing to write through a symlink: ${file}`); continue; } } catch { /* does not exist */ }
|
|
49
|
+
fs.writeFileSync(file, r.content);
|
|
50
|
+
written.push(file);
|
|
51
|
+
}
|
|
52
|
+
return written;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function cmdDnr(args, root) {
|
|
56
|
+
const sub = args._[1];
|
|
57
|
+
const id = String(args._[2] ?? '');
|
|
58
|
+
const flags = args.flags || {};
|
|
59
|
+
if (!sub || !id) return usage();
|
|
60
|
+
|
|
61
|
+
if (sub === 'generate') {
|
|
62
|
+
const r = ensureDetection(root, id, { ...flags, regenerate: true });
|
|
63
|
+
if (!r.ok) { console.error(r.reason); return 4; }
|
|
64
|
+
const { ir, validation } = r.saved;
|
|
65
|
+
const want = flags.format ? String(flags.format) : null;
|
|
66
|
+
if (want && want !== 'all' && !FORMATS.includes(want)) { console.error(`unknown format "${want}"; choose one of ${FORMATS.join(', ')}, or all`); return 4; }
|
|
67
|
+
if (flags.json) { console.log(JSON.stringify({ ir, validation, outputs: want && want !== 'all' ? { [want]: renderDetection(ir, want) } : renderAll(ir) }, null, 2)); return validation.status === 'VALIDATED' ? 0 : 1; }
|
|
68
|
+
console.log(`Detection ${ir.id} for ${ir.findingId}: ${ir.payloadClass} (${ir.grounding}, confidence ${ir.confidence})`);
|
|
69
|
+
console.log(`Validation: ${validation.status} (${validation.counts.passed}/${validation.counts.positives + validation.counts.negatives} synthetic cases)${validation.status === 'VALIDATED' ? '; recurrence rule enabled' : '; NOT enabled'}`);
|
|
70
|
+
for (const l of ir.limitations) console.log(` limitation: ${l}`);
|
|
71
|
+
if (flags.out) { const w = writeOut(path.resolve(String(flags.out)), id, ir, want && want !== 'all' ? [want] : FORMATS); console.log(`Wrote ${w.length} file(s) to ${path.resolve(String(flags.out))}`); }
|
|
72
|
+
else if (want && want !== 'all') { const o = renderDetection(ir, want); console.log(''); console.log(o.applicable ? o.content : `(${want} not applicable: ${o.reason})`); }
|
|
73
|
+
else console.log(`Formats: ${FORMATS.filter((f) => renderDetection(ir, f).applicable).join(', ')} (use --format <name> to print one, --out <dir> to write them)`);
|
|
74
|
+
return validation.status === 'VALIDATED' ? 0 : 1;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (sub === 'validate') {
|
|
78
|
+
const rec = latestRecord(root, id, 'detection');
|
|
79
|
+
if (!rec) { console.error(`No detection for ${id}. Run: dnr generate ${id}`); return 4; }
|
|
80
|
+
const errs = validateDetectionIR(rec.record.ir);
|
|
81
|
+
if (errs.length) { console.error(`The stored detection is invalid: ${errs.join('; ')}`); return 1; }
|
|
82
|
+
const v = validateDetection(rec.record.ir);
|
|
83
|
+
if (flags.json) console.log(JSON.stringify(v, null, 2));
|
|
84
|
+
else { console.log(`Validation: ${v.status} (${v.counts.passed}/${v.cases.length} cases)`); for (const c of v.cases.filter((x) => !x.ok)) console.log(` FAILED: ${c.kind} "${c.name}" ${c.kind === 'positive' ? 'did not fire' : 'fired'}`); if (v.reason) console.log(` ${v.reason}`); }
|
|
85
|
+
return v.status === 'VALIDATED' ? 0 : 1;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (sub === 'hunt') {
|
|
89
|
+
if (!flags.logs) { console.error('dnr hunt needs --logs <file-or-directory>. Nothing was searched.'); return 4; }
|
|
90
|
+
const det = ensureDetection(root, id, flags);
|
|
91
|
+
if (!det.ok) { console.error(det.reason); return 4; }
|
|
92
|
+
const ac = new AbortController();
|
|
93
|
+
const onSig = () => ac.abort();
|
|
94
|
+
process.once('SIGINT', onSig);
|
|
95
|
+
let result;
|
|
96
|
+
try { result = await huntLogs({ ir: det.saved.ir, logsPath: path.resolve(String(flags.logs)), since: flags.since ?? null, until: flags.until ?? null, signal: ac.signal }); } finally { process.off('SIGINT', onSig); }
|
|
97
|
+
const saved = saveRecord(root, id, 'hunt', result);
|
|
98
|
+
if (flags.json) console.log(JSON.stringify({ ...result, stored: saved.ok ? saved.file : null }, null, 2));
|
|
99
|
+
else {
|
|
100
|
+
console.log(`Hunt: ${result.verdict}`);
|
|
101
|
+
console.log(`Logs: ${result.logs.files} file(s), ${result.logs.parsed}/${result.logs.lines} lines parsed${result.window.start ? `, ${result.window.start.slice(0, 10)} to ${result.window.end.slice(0, 10)}` : ', no timestamps'}`);
|
|
102
|
+
if (result.matches.count) console.log(`Matches: ${result.matches.count}${result.matches.firstSeen ? ` (first ${result.matches.firstSeen}, last ${result.matches.lastSeen})` : ''}`);
|
|
103
|
+
for (const s of result.matches.samples.slice(0, 5)) console.log(` ${s.time ?? 'undated'} ${s.method} ${s.path} [${s.matchedOn}] ${s.valuePreview}`);
|
|
104
|
+
for (const c of result.caveats) console.log(`Caveat: ${c}`);
|
|
105
|
+
console.log(result.statement);
|
|
106
|
+
if (!saved.ok) console.log(`(result not stored: ${saved.reason})`);
|
|
107
|
+
}
|
|
108
|
+
return HUNT_EXIT[result.verdict] ?? 11;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (sub === 'window') {
|
|
112
|
+
if (!flags.deployments) { console.error('dnr window needs --deployments <file> (a JSON list of {commit, deployedAt, environment?}). Nothing was computed.'); return 4; }
|
|
113
|
+
const finding = loadFindingFromLastScan(root, id);
|
|
114
|
+
if (!finding) { console.error(`Finding ${id} not found in the last scan.`); return 4; }
|
|
115
|
+
const { deployments, problems } = loadDeployments(path.resolve(String(flags.deployments)), { environment: flags.environment ?? null });
|
|
116
|
+
const hunt = latestRecord(root, id, 'hunt')?.record ?? null;
|
|
117
|
+
const telemetry = flags['telemetry-start'] && flags['telemetry-end'] ? { start: flags['telemetry-start'], end: flags['telemetry-end'] } : hunt?.window ?? null;
|
|
118
|
+
const w = computeExposureWindow({ finding, repo: root, deployments, deploymentProblems: problems, fixCommit: flags['fix-commit'] ?? null, telemetry });
|
|
119
|
+
saveRecord(root, id, 'window', w);
|
|
120
|
+
if (flags.json) console.log(JSON.stringify(w, null, 2)); else console.log(`${formatExposureWindow(w)}\n\n${w.statement}`);
|
|
121
|
+
return w.status === 'complete' ? 0 : 1;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (sub === 'show') {
|
|
125
|
+
const rec = latestRecord(root, id, 'detection');
|
|
126
|
+
if (!rec) { console.error(`No detection for ${id}.`); return 4; }
|
|
127
|
+
if (listRecords(root, id, 'detection').some((r) => r.tampered)) console.error('WARNING: a stored detection record failed its integrity check and was not used.');
|
|
128
|
+
console.log(JSON.stringify(flags.json ? rec.record : { id: rec.record.ir.id, payloadClass: rec.record.ir.payloadClass, grounding: rec.record.ir.grounding, endpoint: rec.record.ir.endpoint, enabled: rec.record.enabled, validation: rec.record.validation.status, proof: rec.record.proof }, null, 2));
|
|
129
|
+
return 0;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (sub === 'status') {
|
|
133
|
+
const v = buildLifecycleView(root, id);
|
|
134
|
+
if (flags.json) console.log(JSON.stringify(v, null, 2)); else console.log(formatLifecycleView(v));
|
|
135
|
+
return 0;
|
|
136
|
+
}
|
|
137
|
+
return usage();
|
|
138
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// Detection intermediate representation (PRD 7, requirement 1). One IR per confirmed finding; every rendered rule, every
|
|
2
|
+
// hunt and every validation is derived from it, so there is exactly one place where the claim "what the exploit looks like"
|
|
3
|
+
// is made.
|
|
4
|
+
//
|
|
5
|
+
// Evidence grounding (requirement 3) is enforced structurally:
|
|
6
|
+
// * the IR is CLOSED-WORLD: `validateDetectionIR` refuses any key it does not list, and none of them can carry an attacker
|
|
7
|
+
// address, a domain, a file hash or a user agent;
|
|
8
|
+
// * every fact that came from somewhere records where (`facts[field] = {source, ref}`), and a fact with no source is not
|
|
9
|
+
// put in the IR at all (it is `null`, and the limitation is stated);
|
|
10
|
+
// * the confidence is computed from the evidence state, never asserted.
|
|
11
|
+
import { canonicalize, sha256 } from '../proof-fabric/reproducer.js';
|
|
12
|
+
import { stateRank } from '../proof-fabric/states.js';
|
|
13
|
+
import { PAYLOAD_CLASSES, classForCwe } from './payload-classes.js';
|
|
14
|
+
import { validateAttackFacts, HTTP_METHODS, PARAM_LOCATIONS } from './attack-facts.js';
|
|
15
|
+
|
|
16
|
+
const DETECTION_IR_SCHEMA = 'agentic-security/detection-ir';
|
|
17
|
+
const DETECTION_IR_VERSION = 1;
|
|
18
|
+
|
|
19
|
+
const IR_KEYS = Object.freeze(['schema', 'version', 'id', 'findingId', 'cwe', 'family', 'attackBehavior', 'endpoint', 'parameter', 'payloadClass', 'affectedOperation', 'processEffect', 'networkEffect', 'dbEffect', 'confidence', 'grounding', 'evidence', 'facts', 'limitations', 'digest']);
|
|
20
|
+
const GROUNDING = Object.freeze(['exploit-confirmed', 'reproduced', 'static-only']);
|
|
21
|
+
|
|
22
|
+
/** "POST /api/export" -> {method, path}; anything else -> null. A finding's own route text is a fact source like any other. */
|
|
23
|
+
export function parseRouteText(text) {
|
|
24
|
+
const m = /^\s*(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+(\/[A-Za-z0-9/_\-.:{}~%]{0,199})\s*$/.exec(String(text ?? ''));
|
|
25
|
+
return m ? { method: m[1], path: m[2] } : null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function groundingFor(proof) {
|
|
29
|
+
const state = proof?.state ?? null;
|
|
30
|
+
if (state && stateRank(state) >= stateRank('EXPLOIT_CONFIRMED')) return 'exploit-confirmed';
|
|
31
|
+
if (state && stateRank(state) >= stateRank('REPRODUCED')) return 'reproduced';
|
|
32
|
+
return 'static-only';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Build the IR for one finding. Returns {ok:true, ir} or {ok:false, reason}.
|
|
37
|
+
* A finding with no proof is refused unless `allowUnproven` is set, in which case the IR says so (`grounding: static-only`)
|
|
38
|
+
* and carries a lower confidence. A finding with no recognisable payload class is refused: there is nothing to detect.
|
|
39
|
+
*/
|
|
40
|
+
export function buildDetectionIR({ finding, proof = null, allowUnproven = false } = {}) {
|
|
41
|
+
if (!finding || typeof finding !== 'object') return { ok: false, reason: 'no finding' };
|
|
42
|
+
const findingId = String(finding.stableId ?? finding.id ?? '');
|
|
43
|
+
if (!findingId) return { ok: false, reason: 'the finding has no stable id' };
|
|
44
|
+
const grounding = groundingFor(proof);
|
|
45
|
+
if (grounding === 'static-only' && !allowUnproven) {
|
|
46
|
+
return { ok: false, reason: `no exploit proof for ${findingId}: prove it first (triage exploit ${findingId} --prove), or pass --allow-unproven to build a lower-confidence detection from the static finding alone` };
|
|
47
|
+
}
|
|
48
|
+
const attack = proof?.reproducer?.attack ?? null;
|
|
49
|
+
const attackErrs = validateAttackFacts(attack);
|
|
50
|
+
if (attackErrs.length) return { ok: false, reason: `the reproducer's attack facts are invalid: ${attackErrs.join('; ')}` };
|
|
51
|
+
|
|
52
|
+
const facts = {};
|
|
53
|
+
const evidence = [];
|
|
54
|
+
const limitations = [];
|
|
55
|
+
if (finding.file) evidence.push({ kind: 'finding', ref: `${finding.file}:${finding.line ?? '?'}`, id: findingId });
|
|
56
|
+
if (proof?.recordDigest) evidence.push({ kind: 'proof', ref: `proof:${proof.recordDigest}`, state: proof.state ?? null, oracle: proof.oracle ?? null });
|
|
57
|
+
if (attack) evidence.push({ kind: 'reproducer', ref: `reproducer:${String(proof?.reproducer ? sha256(canonicalize(proof.reproducer)) : '').slice(0, 16)}` });
|
|
58
|
+
|
|
59
|
+
// payload class: the reproducer's explicit statement wins, then the finding's CWE
|
|
60
|
+
let payloadClass = null;
|
|
61
|
+
if (attack?.payloadClass) { payloadClass = attack.payloadClass; facts.payloadClass = { source: 'reproducer', ref: 'attack.payloadClass' }; }
|
|
62
|
+
else if (classForCwe(finding.cwe)) { payloadClass = classForCwe(finding.cwe); facts.payloadClass = { source: 'finding', ref: `cwe:${finding.cwe}` }; }
|
|
63
|
+
if (!payloadClass) return { ok: false, reason: `no payload class describes ${finding.cwe || 'this finding'}; a detection would have to guess, and a guessed detection is not generated` };
|
|
64
|
+
const def = PAYLOAD_CLASSES[payloadClass];
|
|
65
|
+
|
|
66
|
+
// endpoint: the reproducer's facts, else the finding's own route text
|
|
67
|
+
let endpoint = null;
|
|
68
|
+
if (attack?.method && attack?.path) { endpoint = { method: attack.method, path: attack.path }; facts.endpoint = { source: 'reproducer', ref: 'attack.method+attack.path' }; }
|
|
69
|
+
else {
|
|
70
|
+
const fromFinding = parseRouteText(finding.route ?? finding.endpoint);
|
|
71
|
+
if (fromFinding) { endpoint = fromFinding; facts.endpoint = { source: 'finding', ref: finding.route ? 'route' : 'endpoint' }; }
|
|
72
|
+
}
|
|
73
|
+
if (!endpoint) limitations.push('no endpoint is known for this finding, so no request-level rule is generated; only the payload class is described');
|
|
74
|
+
|
|
75
|
+
let parameter = null;
|
|
76
|
+
if (attack?.parameter) { parameter = { name: attack.parameter, location: attack.location }; facts.parameter = { source: 'reproducer', ref: 'attack.parameter' }; }
|
|
77
|
+
else if (payloadClass !== 'auth-bypass') limitations.push('no vulnerable parameter is known, so the payload test is applied to every query and body value of the endpoint');
|
|
78
|
+
|
|
79
|
+
let sink = null;
|
|
80
|
+
if (attack?.sink) { sink = attack.sink; facts.sink = { source: 'reproducer', ref: 'attack.sink' }; }
|
|
81
|
+
const affectedOperation = { sink, file: finding.file ? String(finding.file) : null, line: Number.isInteger(finding.line) ? finding.line : null };
|
|
82
|
+
|
|
83
|
+
const confidence = Math.max(0.1, Math.min(0.95,
|
|
84
|
+
(grounding === 'exploit-confirmed' ? 0.9 : grounding === 'reproduced' ? 0.7 : 0.4) - (endpoint ? 0 : 0.2) - (parameter || payloadClass === 'auth-bypass' ? 0 : 0.1)));
|
|
85
|
+
if (grounding !== 'exploit-confirmed') limitations.push(grounding === 'reproduced' ? 'the exploit was reproduced but not independently confirmed' : 'the exploit was not executed; this detection rests on static analysis only');
|
|
86
|
+
|
|
87
|
+
const ir = {
|
|
88
|
+
schema: DETECTION_IR_SCHEMA, version: DETECTION_IR_VERSION,
|
|
89
|
+
id: '', findingId,
|
|
90
|
+
cwe: finding.cwe ? String(finding.cwe) : null,
|
|
91
|
+
family: finding.family ? String(finding.family) : payloadClass,
|
|
92
|
+
attackBehavior: def.behavior,
|
|
93
|
+
endpoint, parameter, payloadClass, affectedOperation,
|
|
94
|
+
processEffect: def.effects.process ?? 'none', networkEffect: def.effects.network ?? 'none', dbEffect: def.effects.db ?? 'none',
|
|
95
|
+
confidence: Number(confidence.toFixed(2)), grounding, evidence, facts, limitations, digest: '',
|
|
96
|
+
};
|
|
97
|
+
ir.digest = sha256(canonicalize({ ...ir, id: '', digest: '' }));
|
|
98
|
+
ir.id = `det-${ir.digest.slice(0, 12)}`;
|
|
99
|
+
return { ok: true, ir };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Structural validation of an IR read back from disk or supplied by a caller. Returns a list of problems. */
|
|
103
|
+
export function validateDetectionIR(ir) {
|
|
104
|
+
if (!ir || typeof ir !== 'object' || Array.isArray(ir)) return ['detection IR must be an object'];
|
|
105
|
+
const errs = [];
|
|
106
|
+
for (const k of Object.keys(ir)) if (!IR_KEYS.includes(k)) errs.push(`unknown IR field: ${k}`);
|
|
107
|
+
if (ir.schema !== DETECTION_IR_SCHEMA || ir.version !== DETECTION_IR_VERSION) errs.push('unsupported schema or version');
|
|
108
|
+
if (typeof ir.findingId !== 'string' || !ir.findingId) errs.push('findingId required');
|
|
109
|
+
if (!PAYLOAD_CLASSES[ir.payloadClass]) errs.push(`unknown payload class: ${String(ir.payloadClass)}`);
|
|
110
|
+
if (!GROUNDING.includes(ir.grounding)) errs.push('unknown grounding');
|
|
111
|
+
if (ir.endpoint != null) {
|
|
112
|
+
if (!HTTP_METHODS.includes(ir.endpoint.method) || typeof ir.endpoint.path !== 'string') errs.push('endpoint must be {method, path}');
|
|
113
|
+
else if (validateAttackFacts({ method: ir.endpoint.method, path: ir.endpoint.path }).length) errs.push('endpoint path is not a plain URL path');
|
|
114
|
+
}
|
|
115
|
+
if (ir.parameter != null) {
|
|
116
|
+
if (!PARAM_LOCATIONS.includes(ir.parameter.location) || typeof ir.parameter.name !== 'string') errs.push('parameter must be {name, location}');
|
|
117
|
+
else if (validateAttackFacts({ parameter: ir.parameter.name, location: ir.parameter.location }).length) errs.push('parameter name is not a plain name');
|
|
118
|
+
}
|
|
119
|
+
if (typeof ir.confidence !== 'number' || ir.confidence < 0 || ir.confidence > 1) errs.push('confidence must be between 0 and 1');
|
|
120
|
+
if (!Array.isArray(ir.evidence) || !Array.isArray(ir.limitations)) errs.push('evidence and limitations must be lists');
|
|
121
|
+
if (ir.digest && ir.digest !== sha256(canonicalize({ ...ir, id: '', digest: '' }))) errs.push('digest does not match the content');
|
|
122
|
+
return errs;
|
|
123
|
+
}
|