@clear-capabilities/agentic-security-scanner 0.195.28 → 0.195.30
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 +41 -0
- package/bin/agentic-security.js +13 -4
- package/dist/1340.index.js +11 -6
- package/dist/3500.index.js +943 -0
- package/dist/5616.index.js +146 -19
- package/dist/{3eabd90d6d9c8c8dbbd9.json → 7e58a58b8e6ccdde1659.json} +7 -6
- package/dist/9560.index.js +29 -5
- package/dist/agentic-security.mjs +2 -2
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +7 -6
- package/src/egress/policy.js +79 -1
- package/src/engine.js +1 -1
- package/src/language/CLAUDE.md +1 -1
- package/src/language/haskell-guards.js +26 -5
- package/src/lineage/CLAUDE.md +3 -1
- package/src/lineage/deployment/adapter-cloud.js +5 -2
- package/src/lineage/deployment/attack-paths.js +107 -30
- package/src/lineage/deployment/boundaries-run.js +2 -2
- package/src/lineage/deployment/iam-eval.js +105 -0
- package/src/lineage/deployment/projection.js +10 -4
- package/src/lineage/deployment/proof-binding.js +79 -0
- package/src/llm-validator/bounded-context.js +115 -8
- package/src/llm-validator/index.js +1 -1
- package/src/mcp/CLAUDE.md +6 -0
- package/src/mcp/tools.js +27 -3
- package/src/pipeline/evidence-record.js +1 -1
- package/src/posture/CLAUDE.md +2 -0
- package/src/posture/assurance/candidate-qualification.js +155 -0
- package/src/posture/assurance/release-package-cli.js +44 -2
- package/src/posture/auditor-walkthrough.js +2 -1
- package/src/posture/dnr/detection-ir.js +2 -2
- package/src/posture/dnr/hunt.js +22 -6
- package/src/posture/dnr/log-reader.js +47 -3
- package/src/posture/dnr/matchers.js +43 -3
- package/src/posture/dnr/outputs.js +16 -2
- package/src/posture/dnr/payload-classes.js +12 -2
- package/src/posture/dnr/validate.js +17 -4
- package/src/posture/evaluation/report.js +2 -1
- package/src/posture/evaluation/score.js +24 -7
- package/src/posture/logic-claims.js +15 -14
- package/src/posture/sibling-queue.js +69 -0
- package/src/posture/triage-memory.js +106 -26
- package/src/posture/verification-separation.js +55 -7
- package/src/report/index.js +4 -0
- package/src/runScan.js +5 -0
- package/src/scope/flags.js +1 -0
- package/src/scope/inventory.js +17 -1
- package/src/scope/release-scope.js +80 -0
|
@@ -9,8 +9,15 @@
|
|
|
9
9
|
// invalid a citation names a span or a line that was not sent: the model reasoned about code it was not shown, so the reply
|
|
10
10
|
// is not used (the caller turns it into `escalate`)
|
|
11
11
|
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
12
|
+
// Callers and configuration are collected by name, and the prompt says so:
|
|
13
|
+
//
|
|
14
|
+
// callers the nearest function definition above the finding, then call sites of that name in scanned files of the same
|
|
15
|
+
// language. Matched by name, not resolved through imports or dispatch: a same-named function can appear, and
|
|
16
|
+
// calls through aliases, callbacks or dynamic dispatch are missed.
|
|
17
|
+
// configuration environment keys read in the finding's span, then the scanned configuration files that set them. Dotenv files
|
|
18
|
+
// other than examples are never read, and the value of a secret-like key is masked before redaction.
|
|
19
|
+
//
|
|
20
|
+
// What is not collected is stated in `gaps`, never implied.
|
|
14
21
|
|
|
15
22
|
import { languagePromptExtras } from '../language/context.js';
|
|
16
23
|
|
|
@@ -19,13 +26,13 @@ const lineOf = (x) => (x && Number.isInteger(x.line) && x.line > 0 ? x.line : nu
|
|
|
19
26
|
/**
|
|
20
27
|
* @returns {{spans:{id:string, role:string, file:string, start:number, end:number, text:string}[], gaps:string[]}}
|
|
21
28
|
*/
|
|
22
|
-
export function collectBoundedContext(finding, fileContents, { window = 20, stepWindow = 3, maxSpans = 10 } = {}) {
|
|
29
|
+
export function collectBoundedContext(finding, fileContents, { window = 20, stepWindow = 3, maxSpans = 10, maxCallers = 3, maxConfig = 2 } = {}) {
|
|
23
30
|
const raw = [];
|
|
24
31
|
const gaps = [];
|
|
25
|
-
const add = (role, file, line, w) => {
|
|
32
|
+
const add = (role, file, line, w, mask = null) => {
|
|
26
33
|
const code = fileContents && typeof fileContents[file] === 'string' ? fileContents[file] : null;
|
|
27
34
|
if (code === null) { gaps.push(`${role} file ${file} was not available`); return; }
|
|
28
|
-
const lines = code.split('\n');
|
|
35
|
+
const lines = mask ? code.split('\n').map(mask) : code.split('\n');
|
|
29
36
|
const start = Math.max(1, line - w);
|
|
30
37
|
const end = Math.min(lines.length, line + w);
|
|
31
38
|
if (start > end) return;
|
|
@@ -38,6 +45,12 @@ export function collectBoundedContext(finding, fileContents, { window = 20, step
|
|
|
38
45
|
for (const step of Array.isArray(finding && finding.path) ? finding.path : []) {
|
|
39
46
|
if (lineOf(step)) add('path-step', step.file || finding.file, step.line, stepWindow);
|
|
40
47
|
}
|
|
48
|
+
if (finding && finding.file && lineOf(finding) && typeof (fileContents || {})[finding.file] === 'string') {
|
|
49
|
+
collectCallers(finding, fileContents, { maxCallers, stepWindow, add, gaps });
|
|
50
|
+
collectConfiguration(finding, fileContents, { window, maxConfig, add, gaps });
|
|
51
|
+
} else {
|
|
52
|
+
gaps.push('the finding has no readable source line, so callers and configuration were not collected');
|
|
53
|
+
}
|
|
41
54
|
// Merge spans that overlap or touch in the same file; the first role stays, the others are kept as `alsoRoles`.
|
|
42
55
|
const merged = [];
|
|
43
56
|
for (const s of raw) {
|
|
@@ -49,7 +62,7 @@ export function collectBoundedContext(finding, fileContents, { window = 20, step
|
|
|
49
62
|
id: `S${i + 1}`, role: m.role, ...(m.alsoRoles.length ? { alsoRoles: m.alsoRoles } : {}), file: m.file, start: m.start, end: m.end,
|
|
50
63
|
text: m.lines.slice(m.start - 1, m.end).map((l, k) => `${m.start + k}: ${l}`).join('\n'),
|
|
51
64
|
}));
|
|
52
|
-
if (merged.length > maxSpans) gaps.push(`${merged.length - maxSpans}
|
|
65
|
+
if (merged.length > maxSpans) gaps.push(`${merged.length - maxSpans} span(s) beyond the first ${maxSpans} were not sent`);
|
|
53
66
|
try {
|
|
54
67
|
for (const x of languagePromptExtras(finding, fileContents)) {
|
|
55
68
|
const n = x.text.split('\n').length;
|
|
@@ -57,11 +70,105 @@ export function collectBoundedContext(finding, fileContents, { window = 20, step
|
|
|
57
70
|
}
|
|
58
71
|
} catch { /* related excerpts are advisory */ }
|
|
59
72
|
if (!spans.length) gaps.push('no source text was available for the finding');
|
|
60
|
-
gaps.push('
|
|
61
|
-
gaps.push('configuration files were not collected');
|
|
73
|
+
gaps.push('deployment configuration outside the repository (environment, secrets stores, cloud policy) was not collected');
|
|
62
74
|
return { spans, gaps };
|
|
63
75
|
}
|
|
64
76
|
|
|
77
|
+
// ── callers ─────────────────────────────────────────────────────────────────
|
|
78
|
+
const NOT_A_NAME = new Set(['if', 'for', 'while', 'switch', 'catch', 'return', 'function', 'else', 'do', 'try', 'new', 'typeof', 'await',
|
|
79
|
+
'with', 'sizeof', 'elif', 'except', 'when', 'match', 'case', 'super', 'this', 'self', 'import', 'require', 'where', 'let', 'in']);
|
|
80
|
+
// Lines carrying a CWE-1333 pragma: bounded by MAX_DEF_LINE below, so the polynomial worst case is capped per line.
|
|
81
|
+
const LANGS = [
|
|
82
|
+
{ ext: /\.(?:js|jsx|ts|tsx|mjs|cjs)$/i, defs: [/\bfunction\s*\*?\s*([A-Za-z_$][\w$]*)\s*\(/, /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:function\b|\([^)]*\)\s*=>|[A-Za-z_$][\w$]*\s*=>)/, /^\s*(?:(?:public|private|protected|static|async|get|set)\s+)*([A-Za-z_$][\w$]*)\s*\([^)]*\)\s*\{/] }, // agentic-security-ignore: CWE-1333
|
|
83
|
+
{ ext: /\.py$/i, defs: [/^\s*(?:async\s+)?def\s+([A-Za-z_]\w*)\s*\(/] },
|
|
84
|
+
{ ext: /\.rb$/i, defs: [/^\s*def\s+(?:self\.)?([A-Za-z_]\w*[?!]?)/] },
|
|
85
|
+
{ ext: /\.go$/i, defs: [/^func\s+(?:\([^)]*\)\s*)?([A-Za-z_]\w*)\s*[[(]/] },
|
|
86
|
+
{ ext: /\.rs$/i, defs: [/\bfn\s+([A-Za-z_]\w*)/] },
|
|
87
|
+
{ ext: /\.php$/i, defs: [/\bfunction\s+([A-Za-z_]\w*)\s*\(/] },
|
|
88
|
+
{ ext: /\.(?:java|kt|kts|cs|scala)$/i, defs: [/^\s*(?:(?:public|private|protected|internal|static|final|override|suspend|async|virtual|abstract|synchronized|open)\s+)*(?:fun\s+|def\s+)?(?:[\w<>\[\],.?]+\s+)?([A-Za-z_]\w*)\s*\([^;]*$/] }, // agentic-security-ignore: CWE-1333
|
|
89
|
+
{ ext: /\.hs$/i, defs: [/^([a-z_][\w']*)\s*::/, /^([a-z_][\w']*)\b[^=\n]*=/], bare: true },
|
|
90
|
+
];
|
|
91
|
+
const langOf = (file) => LANGS.find((l) => l.ext.test(file)) || null;
|
|
92
|
+
const escapeRe = (x) => x.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
93
|
+
// The definition patterns run over scanned (untrusted) source. None backtracks exponentially, but the modifier and type groups can go
|
|
94
|
+
// quadratic on a long line, so a line longer than any real definition header is never matched.
|
|
95
|
+
const MAX_DEF_LINE = 400;
|
|
96
|
+
const defName = (lang, line) => {
|
|
97
|
+
if (line.length > MAX_DEF_LINE) return null;
|
|
98
|
+
for (const re of lang.defs) { const m = re.exec(line); if (m && !NOT_A_NAME.has(m[1])) return m[1]; }
|
|
99
|
+
return null;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/** The nearest function definition at or above `line` (1-based), within 400 lines. */
|
|
103
|
+
export function enclosingFunction(file, code, line) {
|
|
104
|
+
const lang = langOf(file);
|
|
105
|
+
if (!lang) return null;
|
|
106
|
+
const lines = code.split('\n');
|
|
107
|
+
for (let i = Math.min(line, lines.length) - 1; i >= 0 && i >= line - 400; i--) {
|
|
108
|
+
const name = defName(lang, lines[i]);
|
|
109
|
+
if (name) return { name, line: i + 1 };
|
|
110
|
+
}
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function collectCallers(finding, fileContents, { maxCallers, stepWindow, add, gaps }) {
|
|
115
|
+
const lang = langOf(finding.file);
|
|
116
|
+
const fn = lang ? enclosingFunction(finding.file, fileContents[finding.file], finding.line) : null;
|
|
117
|
+
if (!fn) { gaps.push('the enclosing function could not be determined, so callers were not collected'); return; }
|
|
118
|
+
const call = lang.bare ? new RegExp(`(?<![\\w'.])${escapeRe(fn.name)}(?![\\w'])`) : new RegExp(`(?<![\\w$])${escapeRe(fn.name)}\\s*\\(`);
|
|
119
|
+
const sites = [];
|
|
120
|
+
for (const [file, code] of Object.entries(fileContents)) {
|
|
121
|
+
if (typeof code !== 'string' || code.length > 1_000_000 || !lang.ext.test(file) || !code.includes(fn.name)) continue;
|
|
122
|
+
code.split('\n').forEach((text, i) => {
|
|
123
|
+
if (!call.test(text) || defName(lang, text) === fn.name) return; // the definition (or a signature) is not a call
|
|
124
|
+
if (file === finding.file && i + 1 >= fn.line && i + 1 <= finding.line) return; // recursion inside the finding's own function
|
|
125
|
+
sites.push({ file, line: i + 1 });
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
if (!sites.length) {
|
|
129
|
+
gaps.push(`no call site of ${fn.name} was found by name in the scanned files of the same language (it may be an entry point, exported, or called dynamically)`);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
for (const s of sites.slice(0, maxCallers)) add('caller', s.file, s.line, stepWindow);
|
|
133
|
+
gaps.push(`callers of ${fn.name} were matched by name, not resolved: a same-named function can appear, and calls through aliases, callbacks or dynamic dispatch are missed`);
|
|
134
|
+
if (sites.length > maxCallers) gaps.push(`${sites.length - maxCallers} further call site(s) of ${fn.name} were not sent`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── configuration ───────────────────────────────────────────────────────────
|
|
138
|
+
const ENV_READS = [
|
|
139
|
+
/process\.env\.([A-Za-z_][\w]*)/g, /process\.env\[\s*['"]([^'"]+)['"]\s*\]/g, /os\.environ(?:\.get)?\s*[[(]\s*['"]([^'"]+)['"]/g,
|
|
140
|
+
/\bgetenv\(\s*['"]([^'"]+)['"]/g, /\b(?:getEnv|lookupEnv)\s+"([^"]+)"/g, /System\.getenv\(\s*"([^"]+)"/g,
|
|
141
|
+
/\bENV(?:\.fetch\(\s*|\[\s*)['"]([^'"]+)['"]/g, /os\.(?:Getenv|LookupEnv)\(\s*"([^"]+)"/g, /env::var\(\s*"([^"]+)"/g,
|
|
142
|
+
];
|
|
143
|
+
const CONFIG_FILE = /(?:^|\/)(?:\.env\.(?:example|sample|template|dist)|[^/]+\.(?:ya?ml|toml|ini|conf|cfg|properties|nix)|docker-compose[^/]*|Dockerfile[^/]*|app\.json)$/i;
|
|
144
|
+
const SECRET_KEY = /secret|token|passw|pwd|credential|private|api[_-]?key|auth/i;
|
|
145
|
+
// Masks the value of a secret-like key; the generic redaction then runs over the whole span.
|
|
146
|
+
const maskSecretValues = (text) => text.replace(/^(\s*(?:export\s+|-\s+)?["']?([\w.-]+)["']?\s*[:=]\s*)(.+)$/, (all, head, key) => (SECRET_KEY.test(key) ? `${head}<masked>` : all));
|
|
147
|
+
|
|
148
|
+
function collectConfiguration(finding, fileContents, { window, maxConfig, add, gaps }) {
|
|
149
|
+
const lines = fileContents[finding.file].split('\n');
|
|
150
|
+
const span = lines.slice(Math.max(0, finding.line - 1 - window), finding.line + window).join('\n');
|
|
151
|
+
const keys = new Set();
|
|
152
|
+
for (const re of ENV_READS) for (const m of span.matchAll(re)) keys.add(m[1]);
|
|
153
|
+
if (!keys.size) { gaps.push("no configuration key is read near the finding, so no configuration file was collected"); return; }
|
|
154
|
+
const found = new Set(); let sent = 0; let more = 0;
|
|
155
|
+
for (const [file, code] of Object.entries(fileContents)) {
|
|
156
|
+
if (typeof code !== 'string' || !CONFIG_FILE.test(file) || code.length > 500_000) continue;
|
|
157
|
+
const cl = code.split('\n');
|
|
158
|
+
for (const key of keys) {
|
|
159
|
+
// the key is escaped before it reaches the pattern, which has no quantifier at all
|
|
160
|
+
const re = new RegExp(`(?<![\\w])${escapeRe(key)}(?![\\w])`); // agentic-security-ignore: CWE-1333
|
|
161
|
+
const i = cl.findIndex((t) => re.test(t));
|
|
162
|
+
if (i < 0) continue;
|
|
163
|
+
found.add(key);
|
|
164
|
+
if (sent < maxConfig) { add('configuration', file, i + 1, 1, maskSecretValues); sent++; } else more++;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
const missing = [...keys].filter((k) => !found.has(k)).sort();
|
|
168
|
+
if (missing.length) gaps.push(`configuration key(s) ${missing.join(', ')} read near the finding are set in no scanned configuration file (they may be set at deploy time)`);
|
|
169
|
+
if (more) gaps.push(`${more} further configuration match(es) were not sent`);
|
|
170
|
+
}
|
|
171
|
+
|
|
65
172
|
/** Render spans as the untrusted excerpt block. */
|
|
66
173
|
export function renderSpans(spans) {
|
|
67
174
|
return spans.map((s) => `[${s.id} ${s.role}${s.alsoRoles ? `+${s.alsoRoles.join('+')}` : ''}] ${s.file}:${s.start}-${s.end}\n${s.text}`).join('\n\n');
|
|
@@ -90,7 +90,7 @@ const MAX_OUTPUT_TOKENS = 512;
|
|
|
90
90
|
// reads as a refusal rather than as "no endpoint configured".
|
|
91
91
|
let _localPresetRefusal = null;
|
|
92
92
|
|
|
93
|
-
export const PROMPT_VERSION = 'v2.
|
|
93
|
+
export const PROMPT_VERSION = 'v2.2-callers-config';
|
|
94
94
|
|
|
95
95
|
// System preamble — embeds a per-request challenge token the model MUST
|
|
96
96
|
// echo, and a strict instruction-priority frame. {{challenge}} and {{nonce}}
|
package/src/mcp/CLAUDE.md
CHANGED
|
@@ -88,6 +88,12 @@ the before and after image, **no content**) and `languageFix.planDigest` after r
|
|
|
88
88
|
caller then sends `apply_fix {finding_id, confirm:true, plan_digest}` (optionally `dry_run:true`, `fixMeta.approval` for a
|
|
89
89
|
high-impact change).
|
|
90
90
|
|
|
91
|
+
**Approval binding (threat-model FR-06.3).** `fixMeta.approval` may carry `expires` (ISO time) and `scope: {tool, action, target,
|
|
92
|
+
change}`: `tool` is `apply_fix`, `action` is `apply-plan` (multi-file) or `apply-patch`, `target` is the finding's stableId, and
|
|
93
|
+
`change` is the plan digest or the digest of the exact edits (path, before and after image hashes). `approvalBindingRefusal` refuses
|
|
94
|
+
an expired approval and one issued for another tool, action, target or change. Under the restricted execution profile
|
|
95
|
+
(`AGENTIC_SECURITY_EGRESS_PROFILE=restricted`) both fields are required; elsewhere an approval without them works as before.
|
|
96
|
+
|
|
91
97
|
Design decision: the caller **cannot supply file content** on this path (`plan_digest` plus `patch` is refused). The server
|
|
92
98
|
re-derives the plan with `planNixFix` over `loadLanguageProject` and refuses unless the digest matches, so "the plan applied is
|
|
93
99
|
exactly the plan previewed" holds by construction, and a changed project file, a changed finding or an altered digest all give
|
package/src/mcp/tools.js
CHANGED
|
@@ -640,7 +640,26 @@ export const explain_finding = {
|
|
|
640
640
|
// FR-307/FR-1002/FR-1003: the high-impact-change approval gate (approval evidence, approver identity, separation of duties),
|
|
641
641
|
// shared by the caller-patch branch and the multi-file plan branch so the two cannot drift. Returns a refusal result, or null
|
|
642
642
|
// when the change is not high-impact or is properly approved.
|
|
643
|
-
|
|
643
|
+
// Threat-model PRD FR-06.3: an approval is bound to what it approved. With a `scope` it names the tool, the action, the target finding
|
|
644
|
+
// and the change (the digest of the exact edits), and with `expires` it lapses. Under the restricted execution profile
|
|
645
|
+
// (AGENTIC_SECURITY_EGRESS_PROFILE=restricted) both are required; elsewhere a supplied scope or expiry is still enforced. An approval
|
|
646
|
+
// copied to another finding, another change or another tool, or used after it expired, is refused.
|
|
647
|
+
export function approvalBindingRefusal(approval, expected, { now = Date.now(), restricted = process.env.AGENTIC_SECURITY_EGRESS_PROFILE === 'restricted' } = {}) {
|
|
648
|
+
if (restricted && (!approval.expires || !approval.scope)) return 'under the restricted profile an approval must carry an expiry and a scope {tool, action, target, change}';
|
|
649
|
+
if (approval.expires !== undefined) {
|
|
650
|
+
const t = Date.parse(approval.expires);
|
|
651
|
+
if (!Number.isFinite(t)) return 'the approval expiry is not a date';
|
|
652
|
+
if (!(t > now)) return 'the approval has expired';
|
|
653
|
+
}
|
|
654
|
+
if (approval.scope !== undefined) {
|
|
655
|
+
const sc = approval.scope;
|
|
656
|
+
if (!sc || typeof sc !== 'object') return 'the approval scope is not an object';
|
|
657
|
+
for (const k of ['tool', 'action', 'target', 'change']) if (sc[k] !== expected[k]) return `the approval was issued for another ${k}`;
|
|
658
|
+
}
|
|
659
|
+
return null;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function _highImpactApprovalRefusal(ctx, materialClassification, fixMeta, expected = null) {
|
|
644
663
|
if (!materialClassification.highImpactCategories.length) return null;
|
|
645
664
|
const cats = materialClassification.highImpactCategories.join(', ');
|
|
646
665
|
const approval = fixMeta && typeof fixMeta === 'object' ? fixMeta.approval : null;
|
|
@@ -654,6 +673,10 @@ function _highImpactApprovalRefusal(ctx, materialClassification, fixMeta) {
|
|
|
654
673
|
materialClassification,
|
|
655
674
|
};
|
|
656
675
|
}
|
|
676
|
+
if (expected) {
|
|
677
|
+
const bound = approvalBindingRefusal(approval, expected);
|
|
678
|
+
if (bound) return { _meta: META, applied: false, reason: `high-impact change (${cats}) approval rejected: ${bound}`, materialClassification };
|
|
679
|
+
}
|
|
657
680
|
const approverRegistry = loadApproverRegistry(ctx.sessionRoot);
|
|
658
681
|
const requiredRoles = requiredRolesFor(approverRegistry, materialClassification.highImpactCategories);
|
|
659
682
|
const identityCheck = verifyApprover(approverRegistry, approval.approvedBy, requiredRoles);
|
|
@@ -739,7 +762,7 @@ async function _applyMultiFilePlan({ f, planDigest, dryRun, fixMeta, ctx, status
|
|
|
739
762
|
if (!targets.ok) return refuse(targets.reason);
|
|
740
763
|
const classification = classifyFixMaterialRisk(Object.fromEntries(plan.edits.map((e) => [e.file, { before: e.before, after: e.after }])));
|
|
741
764
|
if (!dryRun) {
|
|
742
|
-
const approvalRefusal = _highImpactApprovalRefusal(ctx, classification, fixMeta);
|
|
765
|
+
const approvalRefusal = _highImpactApprovalRefusal(ctx, classification, fixMeta, { tool: 'apply_fix', action: 'apply-plan', target: f.stableId || f.id, change: planDigest });
|
|
743
766
|
if (approvalRefusal) return { ...approvalRefusal, multiFile: true };
|
|
744
767
|
}
|
|
745
768
|
// The verification gates (syntax, rescan with no new finding, effective value changed as intended) run inside the lifecycle,
|
|
@@ -942,7 +965,8 @@ export const apply_fix = {
|
|
|
942
965
|
if (dry_run) {
|
|
943
966
|
return { _meta: META, applied: false, dryRun: true, verified: true, files: Object.keys(confinedAbs), summary: verdict.summary, verificationRecord: verdict.verificationRecord ?? null, ...verificationFields(verdict.verificationRecord), materialClassification, ...(fixVerification ? additiveFields(fixVerification) : {}) };
|
|
944
967
|
}
|
|
945
|
-
const
|
|
968
|
+
const changeDigest = _sha256(JSON.stringify(Object.entries(filesForMaterialClassification).sort(([a], [b]) => (a < b ? -1 : 1)).map(([k, v]) => [k, _sha256(v.before), _sha256(v.after)])));
|
|
969
|
+
const approvalRefusal = _highImpactApprovalRefusal(ctx, materialClassification, fixMeta, { tool: 'apply_fix', action: 'apply-patch', target: f.stableId || f.id, change: changeDigest });
|
|
946
970
|
if (approvalRefusal) return approvalRefusal;
|
|
947
971
|
const written = [];
|
|
948
972
|
try {
|
|
@@ -55,7 +55,7 @@ export function buildScanRecord({ root, scan, meta, argv = [], engineVersion, mo
|
|
|
55
55
|
tool: { name: 'agentic-security', version: engineVersion || null },
|
|
56
56
|
source: sourceState(root),
|
|
57
57
|
invocation: { flags: invocationSummary(argv), mode: mode || (meta && meta.mode) || 'full' },
|
|
58
|
-
ignorePolicy: scan.scope ? { mode: scan.scope.policy.mode, ruleDigest: scan.scope.policy.ruleDigest, trackedFileSemantics: scan.scope.policy.trackedFileSemantics } : { mode: 'legacy-walk', ruleDigest: null, trackedFileSemantics: false },
|
|
58
|
+
ignorePolicy: scan.scope ? { mode: scan.scope.policy.mode, ruleDigest: scan.scope.policy.ruleDigest, trackedFileSemantics: scan.scope.policy.trackedFileSemantics, inventoryDigest: scan.scope.inventoryDigest ?? null } : { mode: 'legacy-walk', ruleDigest: null, trackedFileSemantics: false, inventoryDigest: null },
|
|
59
59
|
rules: { rulesetVersion: scan._rulesetVersion || null, rulesetSource: scan._rulesetVersionSource || null },
|
|
60
60
|
model: model ? { provider: model.provider, model: model.model, egress: model.egress, stages: model.stages } : { configured: false },
|
|
61
61
|
capability: { status: h.status || null, conditions: Array.isArray(h.conditions) ? h.conditions.length : 0 },
|
package/src/posture/CLAUDE.md
CHANGED
|
@@ -73,6 +73,8 @@ function is not exported.
|
|
|
73
73
|
|
|
74
74
|
Its contract is **recall-preserving, same precedent as `falsification.js` / `dataflow/proof-gate.js`**: it never removes a finding, never touches `severity`, and never asserts `unreachable` without positive evidence — a negative verdict additionally requires the intra-repo import graph to be hole-free *along the reachable set* (an unresolved relative import or a non-literal `require(x)` in a reachable file hides a possible edge, so every would-be `unreachable` degrades to `unknown`). `null`/`'unknown'` is a first-class state and is **not** the same as `false`. Wired in `engine.js` after the entry-point inventory is built and after every finding has been appended (multi-sink and cross-language chains included) so nothing escapes annotation; this is the one annotator that deliberately runs after `why-fired`.
|
|
75
75
|
|
|
76
|
+
**Threat-model hardening (local, threat-model PRD).** `verification-separation.js` `consensusOf` now votes by independent evidence group (a shared `evidence.observationId`, `generator` or `lineage`, else one verifier), keeps the per-lens counts, reports `conflict` and a `refutationAuthority`; `refutationRemoves` lets a refutation take a critical finding out of the queue only with `sufficient` authority. `triage-memory.js`: a family/directory bucket is advisory (no confidence change); only an exact (stable id + file) decision applies, and for critical/high it also needs a reviewer, expiry, matching scope, unchanged fingerprint and the install's HMAC signature (`pastDecision.status`, `pastDecisionApplies`). `assurance/candidate-qualification.js`: the candidate packet and `evaluateCandidate`, which recomputes gates G-01..G-07 from case evidence (met, violated, unresolved, blocked) and never inherits a pass; `writeCandidateDecision` is atomic and idempotent.
|
|
77
|
+
|
|
76
78
|
**Enforced verification separation (R7)** — `verification-separation.js`. The falsification pass could already try to *disprove* a finding; what it could not do was prove the checker was not the producer. This module supplies that structural guarantee:
|
|
77
79
|
|
|
78
80
|
- `recordProducer(finding, producerId)` stamps provenance **write-once** — a later party cannot re-stamp itself as producer to manufacture separation.
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// Candidate qualification (threat-model PRD FR-10.1, FR-10.2, section 6 "Candidate release gates").
|
|
2
|
+
//
|
|
3
|
+
// A release claim is qualified for an EXACT scanner, target artifact, deployment profile, policy and observation window, and only for
|
|
4
|
+
// the cases it passes. The packet carries those identities and one record per required case (requirement, gate, expected and observed
|
|
5
|
+
// outcome, verdict and reason code, artifact and policy bindings, evidence digests, expiry, owner). The decision is recomputed here from
|
|
6
|
+
// the evidence, never read from the packet: a case whose bindings do not match the packet's target, that has expired, whose feed is
|
|
7
|
+
// stale or whose legitimate control failed keeps its gate open, and the candidate is qualified only when every required gate is met.
|
|
8
|
+
// A higher-level status never inherits a pass from a lower-level one: a gate with no case is unresolved, not met.
|
|
9
|
+
//
|
|
10
|
+
// Outcome per case: met (the expectation held), violated (it did not), unresolved (evidence missing, stale, expired or differently
|
|
11
|
+
// scoped) or blocked (a prerequisite such as an attested runner or an independent population is unavailable). Pure apart from the
|
|
12
|
+
// optional signed write, which is atomic.
|
|
13
|
+
//
|
|
14
|
+
// Evidence tiers stay separate qualifications (PRD section 1 and section 8). Each case carries `tier`: `local` (loopback fixtures on
|
|
15
|
+
// this host, the default), `staging` (the owner-authorized staging deployment) or `independent` (an independently held population or
|
|
16
|
+
// reviewer). A gate the caller declares in `requiredTiers` is met only by cases of that tier: local evidence never satisfies a staging
|
|
17
|
+
// or independent gate by being more of it. A decision records the target it was computed for, and `revalidateDecision` refuses it for
|
|
18
|
+
// any other artifact, policy or scanner bundle, so an upgrade invalidates candidate-bound evidence and a rollback is qualified only by
|
|
19
|
+
// the earlier artifact's own unexpired decision (FR-10.4).
|
|
20
|
+
import crypto from 'node:crypto';
|
|
21
|
+
import fs from 'node:fs';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
|
|
24
|
+
const CANDIDATE_SCHEMA = 'agentic-security/candidate-packet';
|
|
25
|
+
const CANDIDATE_VERSION = 1;
|
|
26
|
+
export const GATES = Object.freeze({
|
|
27
|
+
'G-01': 'code regression', 'G-02': 'scan and graph coverage', 'G-03': 'prevention and repair', 'G-04': 'evidence and harness integrity',
|
|
28
|
+
'G-05': 'detection and response', 'G-06': 'independent accuracy', 'G-07': 'operability',
|
|
29
|
+
});
|
|
30
|
+
const EXPECT = new Set(['positive', 'negative', 'legitimate']);
|
|
31
|
+
const TIERS = new Set(['local', 'staging', 'independent']);
|
|
32
|
+
const DOMAIN = `${CANDIDATE_SCHEMA}@1\n`;
|
|
33
|
+
const canon = (v) => {
|
|
34
|
+
if (v === null || typeof v !== 'object') return JSON.stringify(v ?? null);
|
|
35
|
+
if (Array.isArray(v)) return `[${v.map(canon).join(',')}]`;
|
|
36
|
+
// an undefined value is absent, exactly as JSON would carry it, so a packet and its JSON round trip have one digest
|
|
37
|
+
return `{${Object.keys(v).filter((k) => v[k] !== undefined).sort().map((k) => `${JSON.stringify(k)}:${canon(v[k])}`).join(',')}}`;
|
|
38
|
+
};
|
|
39
|
+
export const packetDigest = (p) => `sha256:${crypto.createHash('sha256').update(canon({ ...p, signature: undefined })).digest('hex')}`;
|
|
40
|
+
|
|
41
|
+
/** Sign a packet with an Ed25519 private key; `verifyCandidatePacket` checks it with the key the caller trusts. */
|
|
42
|
+
export function signCandidatePacket(packet, privateKeyPem) {
|
|
43
|
+
const body = { schema: CANDIDATE_SCHEMA, version: CANDIDATE_VERSION, ...packet };
|
|
44
|
+
delete body.signature;
|
|
45
|
+
return { ...body, signature: crypto.sign(null, Buffer.from(DOMAIN + canon(body)), privateKeyPem).toString('base64') };
|
|
46
|
+
}
|
|
47
|
+
export function verifyCandidatePacket(packet, publicKeyPem) {
|
|
48
|
+
if (!packet || packet.schema !== CANDIDATE_SCHEMA) return { ok: false, code: 'FORMAT' };
|
|
49
|
+
// a packet from a newer or older writer is refused by name, never read with this version's meaning
|
|
50
|
+
if (packet.version !== CANDIDATE_VERSION) return { ok: false, code: 'UNSUPPORTED_VERSION', version: packet.version ?? null };
|
|
51
|
+
const { signature, ...body } = packet;
|
|
52
|
+
try { return crypto.verify(null, Buffer.from(DOMAIN + canon(body)), publicKeyPem, Buffer.from(String(signature), 'base64')) ? { ok: true, code: 'VERIFIED' } : { ok: false, code: 'SIGNATURE' }; } catch { return { ok: false, code: 'SIGNATURE' }; }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Judge one case record against the packet's target. */
|
|
56
|
+
function judgeCase(c, packet, now) {
|
|
57
|
+
const t = packet.target || {};
|
|
58
|
+
const miss = [];
|
|
59
|
+
for (const k of ['caseId', 'requirement', 'gate', 'expected', 'verdict', 'owner', 'expires']) if (c[k] === undefined || c[k] === null || c[k] === '') miss.push(k);
|
|
60
|
+
if (miss.length) return { outcome: 'unresolved', reason: `missing ${miss.join(', ')}`, owner: c.owner ?? null };
|
|
61
|
+
if (!GATES[c.gate] || !EXPECT.has(c.expected)) return { outcome: 'unresolved', reason: 'unknown gate or expectation' };
|
|
62
|
+
if (c.tier !== undefined && !TIERS.has(c.tier)) return { outcome: 'unresolved', reason: 'unknown evidence tier' };
|
|
63
|
+
if (c.blocked) return { outcome: 'blocked', reason: String(c.blocked) };
|
|
64
|
+
if (!(Date.parse(c.expires) > now)) return { outcome: 'unresolved', reason: 'the evidence has expired' };
|
|
65
|
+
if (c.artifactDigest && t.artifactDigest && c.artifactDigest !== t.artifactDigest) return { outcome: 'unresolved', reason: 'the evidence was produced against another artifact' };
|
|
66
|
+
if (c.policyDigest && t.policyDigest && c.policyDigest !== t.policyDigest) return { outcome: 'unresolved', reason: 'the evidence was produced under another policy' };
|
|
67
|
+
if (c.scannerBundle && packet.scanner?.bundleDigest && c.scannerBundle !== packet.scanner.bundleDigest) return { outcome: 'unresolved', reason: 'the evidence came from another scanner bundle' };
|
|
68
|
+
if (c.requiresArtifactBinding !== false && !c.artifactDigest) return { outcome: 'unresolved', reason: 'the evidence is not bound to an artifact' };
|
|
69
|
+
const o = c.observed;
|
|
70
|
+
if (!o || typeof o !== 'object') return { outcome: 'unresolved', reason: 'no observation is recorded' };
|
|
71
|
+
// the observation and the expectation stay separately inspectable; met/violated is derived, not asserted
|
|
72
|
+
if (c.kind === 'detection') {
|
|
73
|
+
// a detection case judges the signal, not the effect: positive means the rule must fire, negative that it must stay quiet,
|
|
74
|
+
// and an observation that does not say whether the rule fired leaves the case open
|
|
75
|
+
if (typeof o.detected !== 'boolean') return { outcome: 'unresolved', reason: 'the observation does not record whether the detection fired' };
|
|
76
|
+
if (c.expected === 'positive') return o.detected ? { outcome: 'met', reason: 'the detection fired on the reproduced effect' } : { outcome: 'violated', reason: 'the detection missed the reproduced effect' };
|
|
77
|
+
return o.detected ? { outcome: 'violated', reason: 'the detection fired where it must not' } : { outcome: 'met', reason: 'the detection stayed quiet where it must' };
|
|
78
|
+
}
|
|
79
|
+
const forbidden = o.forbiddenEffect === true; const legit = o.legitimateEffect === true;
|
|
80
|
+
if (c.expected === 'positive') return forbidden ? { outcome: 'met', reason: 'the vulnerable control reproduced the forbidden effect' } : { outcome: 'violated', reason: 'the vulnerable control did not reproduce the effect, so the fixture shows nothing' };
|
|
81
|
+
if (c.expected === 'negative') return forbidden ? { outcome: 'violated', reason: 'the forbidden effect occurred on the fixed artifact' } : { outcome: 'met', reason: 'no forbidden effect' };
|
|
82
|
+
return legit && !forbidden ? { outcome: 'met', reason: 'the legitimate operation succeeded' } : { outcome: 'violated', reason: 'the legitimate operation did not succeed' };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Recompute every gate from the packet's evidence.
|
|
87
|
+
* @param {object} packet
|
|
88
|
+
* @param {{requiredGates?:string[], requiredCases?:string[], requiredTiers?:Object<string,string>, now?:number, maxFeedAgeMs?:number}} o
|
|
89
|
+
* @returns {{status:'qualified'|'not-qualified', gates:object, cases:object[], blockers:object[], tiers:object}}
|
|
90
|
+
*/
|
|
91
|
+
export function evaluateCandidate(packet, { requiredGates = Object.keys(GATES), requiredCases = [], requiredTiers = {}, now = Date.now(), maxFeedAgeMs = 15 * 60 * 1000 } = {}) {
|
|
92
|
+
const cases = (packet.cases || []).map((c) => ({ caseId: c.caseId ?? null, gate: c.gate ?? null, requirement: c.requirement ?? null, owner: c.owner ?? null, tier: c.tier ?? 'local', ...judgeCase(c, packet, now) }));
|
|
93
|
+
const blockers = [];
|
|
94
|
+
for (const id of requiredCases) if (!cases.some((c) => c.caseId === id)) { cases.push({ caseId: id, gate: null, outcome: 'unresolved', reason: 'a required case has no record' }); blockers.push({ gate: null, caseId: id, reason: 'missing required case' }); }
|
|
95
|
+
// gate-level evidence that is not a case
|
|
96
|
+
const extra = {};
|
|
97
|
+
if (!packet.scope || packet.scope.status !== 'satisfied') extra['G-02'] = { outcome: 'unresolved', reason: packet.scope ? `the scope reconciliation is ${packet.scope.status}` : 'no scope reconciliation is in the packet' };
|
|
98
|
+
if (!packet.runner || packet.runner.attested !== true) extra['G-04'] = { outcome: 'blocked', reason: packet.runner?.reason || 'the runner did not attest the confinement the execution cases need' };
|
|
99
|
+
const ev = packet.evaluation;
|
|
100
|
+
if (!ev || ev.status !== 'measured') extra['G-06'] = { outcome: 'blocked', reason: ev?.reason || 'independent accuracy is unmeasured for this candidate' };
|
|
101
|
+
else if (ev.meetsFloors !== true) extra['G-06'] = { outcome: 'violated', reason: 'the measured population does not meet the registered floors' };
|
|
102
|
+
for (const f of packet.telemetry?.feeds || []) {
|
|
103
|
+
if (!f.lastHeartbeat || now - Date.parse(f.lastHeartbeat) > maxFeedAgeMs) { extra['G-05'] = { outcome: 'unresolved', reason: `feed ${f.name} is stale or has no heartbeat` }; break; }
|
|
104
|
+
}
|
|
105
|
+
const gates = {};
|
|
106
|
+
for (const g of requiredGates) {
|
|
107
|
+
const need = requiredTiers[g];
|
|
108
|
+
const ofGate = cases.filter((c) => c.gate === g);
|
|
109
|
+
// a case of another tier is reported, but it cannot meet a gate that names its tier
|
|
110
|
+
const mine = need ? ofGate.filter((c) => c.tier === need) : ofGate;
|
|
111
|
+
const rank = { violated: 3, blocked: 2, unresolved: 1, met: 0 };
|
|
112
|
+
let worst = mine.reduce((w, c) => (rank[c.outcome] > rank[w] ? c.outcome : w), 'met');
|
|
113
|
+
const reasons = mine.filter((c) => c.outcome !== 'met').map((c) => `${c.caseId}: ${c.reason}`);
|
|
114
|
+
if (extra[g]) { if (rank[extra[g].outcome] > rank[worst]) worst = extra[g].outcome; reasons.push(extra[g].reason); }
|
|
115
|
+
// no evidence is not a pass: a gate with no case and no gate-level evidence stays unresolved
|
|
116
|
+
if (need && !mine.length) { worst = rank[worst] > rank.unresolved ? worst : 'unresolved'; reasons.push(`no ${need} evidence for this gate${ofGate.length ? ` (${ofGate.length} case(s) of another tier do not count)` : ''}`); }
|
|
117
|
+
else if (!mine.length && !extra[g] && !(g === 'G-02' && packet.scope?.status === 'satisfied') && !(g === 'G-04' && packet.runner?.attested === true) && !(g === 'G-06' && ev?.status === 'measured')) { worst = 'unresolved'; reasons.push('no evidence for this gate'); }
|
|
118
|
+
gates[g] = { name: GATES[g], outcome: worst, reasons };
|
|
119
|
+
if (worst !== 'met') blockers.push({ gate: g, reason: reasons.join('; ') });
|
|
120
|
+
}
|
|
121
|
+
const tiers = { local: 0, staging: 0, independent: 0 };
|
|
122
|
+
for (const c of cases) if (tiers[c.tier] !== undefined) tiers[c.tier]++;
|
|
123
|
+
return { status: blockers.length ? 'not-qualified' : 'qualified', scope: packet.target ? { ...packet.target } : null, scannerBundle: packet.scanner?.bundleDigest ?? null, packetDigest: packetDigest(packet), gates, cases, blockers, tiers };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Is a recorded decision still the decision for this target? A decision qualifies only the artifact, policy and scanner bundle it was
|
|
128
|
+
* computed for and only until its earliest case expiry, so an upgrade or policy change invalidates it and a rollback is covered only by
|
|
129
|
+
* the earlier artifact's own decision.
|
|
130
|
+
* @param {object} decision an `evaluateCandidate` result
|
|
131
|
+
* @param {{artifactDigest?:string, policyDigest?:string, scannerBundle?:string}} target
|
|
132
|
+
*/
|
|
133
|
+
export function revalidateDecision(decision, target, { packet = null, now = Date.now() } = {}) {
|
|
134
|
+
if (!decision || typeof decision !== 'object' || !decision.scope) return { valid: false, reason: 'no recorded decision scope' };
|
|
135
|
+
if (decision.status !== 'qualified') return { valid: false, reason: 'the recorded decision did not qualify the candidate' };
|
|
136
|
+
if (!target || !target.artifactDigest || decision.scope.artifactDigest !== target.artifactDigest) return { valid: false, reason: 'the artifact differs from the one the decision qualified' };
|
|
137
|
+
if (decision.scope.policyDigest && decision.scope.policyDigest !== target.policyDigest) return { valid: false, reason: 'the deployment policy differs from the one the decision qualified' };
|
|
138
|
+
if (decision.scannerBundle && target.scannerBundle && decision.scannerBundle !== target.scannerBundle) return { valid: false, reason: 'the scanner bundle differs from the one the decision qualified' };
|
|
139
|
+
if (packet) {
|
|
140
|
+
if (packetDigest(packet) !== decision.packetDigest) return { valid: false, reason: 'the packet is not the one the decision was computed from' };
|
|
141
|
+
const soonest = Math.min(...(packet.cases || []).map((c) => Date.parse(c.expires)).filter(Number.isFinite));
|
|
142
|
+
if (Number.isFinite(soonest) && !(soonest > now)) return { valid: false, reason: 'evidence the decision rests on has expired' };
|
|
143
|
+
}
|
|
144
|
+
return { valid: true, reason: 'the decision covers this artifact, policy and scanner bundle' };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Write a decision atomically; an identical retried write leaves the same bytes. */
|
|
148
|
+
export function writeCandidateDecision(file, decision) {
|
|
149
|
+
const body = `${canon(decision)}\n`;
|
|
150
|
+
try { if (fs.readFileSync(file, 'utf8') === body) return { written: false, unchanged: true }; } catch { /* absent */ }
|
|
151
|
+
const tmp = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`);
|
|
152
|
+
fs.writeFileSync(tmp, body, { mode: 0o644 });
|
|
153
|
+
fs.renameSync(tmp, file);
|
|
154
|
+
return { written: true, unchanged: false };
|
|
155
|
+
}
|
|
@@ -2,9 +2,16 @@
|
|
|
2
2
|
//
|
|
3
3
|
// release-package build [path] --private-key <pem> [--output <file>] [--fix-records <file>] [--public-key-out <file>]
|
|
4
4
|
// release-package verify <package.json> --public-key <pem> [--require kind,kind]
|
|
5
|
+
// release-package qualify <candidate-packet.json> --public-key <pem> [--require-case id,id] [--require-gate G-01,G-03]
|
|
6
|
+
// [--require-tier G-05=staging,...] [--output <decision.json>] [--format text|json]
|
|
7
|
+
//
|
|
8
|
+
// `qualify` is the strict candidate qualification of the threat-model PRD (FR-10.3): it verifies the packet's signature, recomputes
|
|
9
|
+
// every gate from the case evidence (`candidate-qualification.js`) and exits 0 only when the candidate is qualified. It never changes
|
|
10
|
+
// what an ordinary scan exits with.
|
|
5
11
|
//
|
|
6
12
|
// `io.git(root, ...args)` answers a read-only git query (or null); the caller supplies it so this layer never spawns a process itself.
|
|
7
|
-
// Exit codes: 0 built or
|
|
13
|
+
// Exit codes: 0 built, verified or qualified; 1 the scan state failed its integrity check, a package was rejected or a candidate was not
|
|
14
|
+
// qualified; 2 a usage problem or unreadable input.
|
|
8
15
|
//
|
|
9
16
|
// What goes in: the scan's health record and one record per finding (and, with --fix-records, one verification record per fix claimed). The proof tier of a
|
|
10
17
|
// finding is whatever the scan recorded; a finding that recorded none is `taint-proven` when the taint engine found its path and `unproven` otherwise. An
|
|
@@ -18,8 +25,9 @@ import { buildSnapshotRef } from './evidence-registry.js';
|
|
|
18
25
|
import { buildReleasePackage, signReleasePackage } from './release-package.js';
|
|
19
26
|
import { releaseVerdict, verifyStatedVerdict } from './release-verdict.js';
|
|
20
27
|
import { verifyReleasePackage } from './release-package-verify.js';
|
|
28
|
+
import { evaluateCandidate, verifyCandidatePacket, writeCandidateDecision, GATES } from './candidate-qualification.js';
|
|
21
29
|
|
|
22
|
-
const USAGE = 'Usage: agentic-security release-package build [path] --private-key <pem> [--output <file>] [--fix-records <file>] [--public-key-out <file>]\n agentic-security release-package verify <package.json> --public-key <pem> [--require kind,kind]';
|
|
30
|
+
const USAGE = 'Usage: agentic-security release-package build [path] --private-key <pem> [--output <file>] [--fix-records <file>] [--public-key-out <file>]\n agentic-security release-package verify <package.json> --public-key <pem> [--require kind,kind]\n agentic-security release-package qualify <candidate-packet.json> --public-key <pem> [--require-case id,id] [--require-gate G-01,G-03] [--require-tier G-05=staging] [--output <decision.json>] [--format text|json]';
|
|
23
31
|
const DEFAULT_OUT = 'release-package.json';
|
|
24
32
|
|
|
25
33
|
|
|
@@ -104,11 +112,45 @@ async function verify(args, say) {
|
|
|
104
112
|
return 1;
|
|
105
113
|
}
|
|
106
114
|
|
|
115
|
+
const listFlag = (v) => String(v ?? '').split(',').map((x) => x.trim()).filter(Boolean);
|
|
116
|
+
|
|
117
|
+
async function qualify(args, say) {
|
|
118
|
+
const file = args._[2];
|
|
119
|
+
const keyFile = args.flags['public-key'];
|
|
120
|
+
if (!file || !keyFile) { say.err(USAGE); return 2; }
|
|
121
|
+
let packet; let key;
|
|
122
|
+
try { packet = JSON.parse(fs.readFileSync(path.resolve(file), 'utf8')); key = fs.readFileSync(path.resolve(String(keyFile)), 'utf8'); } catch (e) { say.err(`Could not read the input: ${e.message}`); return 2; }
|
|
123
|
+
const requiredGates = args.flags['require-gate'] ? listFlag(args.flags['require-gate']) : Object.keys(GATES);
|
|
124
|
+
const unknown = requiredGates.filter((g) => !GATES[g]);
|
|
125
|
+
if (unknown.length) { say.err(`Unknown gate: ${unknown.join(', ')}. Gates: ${Object.keys(GATES).join(', ')}.`); return 2; }
|
|
126
|
+
const requiredTiers = {};
|
|
127
|
+
for (const pair of listFlag(args.flags['require-tier'])) {
|
|
128
|
+
const [g, t] = pair.split('=');
|
|
129
|
+
if (!GATES[g] || !['local', 'staging', 'independent'].includes(t)) { say.err(`--require-tier takes gate=tier pairs (tier local, staging or independent), got ${pair}`); return 2; }
|
|
130
|
+
requiredTiers[g] = t;
|
|
131
|
+
}
|
|
132
|
+
const sig = verifyCandidatePacket(packet, key);
|
|
133
|
+
if (!sig.ok) { say.err(`${sig.code}: the candidate packet ${sig.code === 'UNSUPPORTED_VERSION' ? `has version ${sig.version}, which this scanner does not read` : 'did not verify under the given key'}`); say.err('candidate NOT QUALIFIED'); return 1; }
|
|
134
|
+
const d = evaluateCandidate(packet, { requiredGates, requiredCases: listFlag(args.flags['require-case']), requiredTiers });
|
|
135
|
+
if (args.flags.output) {
|
|
136
|
+
try { writeCandidateDecision(path.resolve(String(args.flags.output)), d); } catch (e) { say.err(`Could not write the decision: ${e.message}`); return 2; }
|
|
137
|
+
}
|
|
138
|
+
if (args.flags.format === 'json') say.out(JSON.stringify(d, null, 2));
|
|
139
|
+
else {
|
|
140
|
+
const t = d.scope || {};
|
|
141
|
+
say.out(`candidate ${d.status === 'qualified' ? 'QUALIFIED' : 'NOT QUALIFIED'} for ${t.service || t.repository || 'an unnamed target'} artifact ${String(t.artifactDigest || 'unbound').slice(0, 19)} (${t.deploymentProfile || 'no profile'})`);
|
|
142
|
+
for (const [g, x] of Object.entries(d.gates)) say.out(` ${g} ${x.name}: ${x.outcome}${x.reasons.length ? ` (${x.reasons.join('; ')})` : ''}`);
|
|
143
|
+
say.out(` cases by tier: local ${d.tiers.local}, staging ${d.tiers.staging}, independent ${d.tiers.independent}`);
|
|
144
|
+
}
|
|
145
|
+
return d.status === 'qualified' ? 0 : 1;
|
|
146
|
+
}
|
|
147
|
+
|
|
107
148
|
export async function cmdReleasePackage(args, io = {}) {
|
|
108
149
|
const say = { out: io.out || ((s) => console.log(s)), err: io.err || ((s) => console.error(s)) };
|
|
109
150
|
const sub = args._[1];
|
|
110
151
|
if (sub === 'build') return build(args, say, io.git || (() => null));
|
|
111
152
|
if (sub === 'verify') return verify(args, say);
|
|
153
|
+
if (sub === 'qualify') return qualify(args, say);
|
|
112
154
|
say.err(USAGE);
|
|
113
155
|
return 2;
|
|
114
156
|
}
|
|
@@ -64,6 +64,7 @@ import { strengthOfControl as _strengthOfControl } from './coverage-strength.js'
|
|
|
64
64
|
// visible regression on common real-world author names, not a fix.
|
|
65
65
|
import { sanitizeForTerminal, pseudonymizeAuthor, PROVENANCE_COMPLIANCE_DISCLAIMER } from './provenance/schema.js';
|
|
66
66
|
import { evaluateGraphFlowPredicate, buildObligationMappingFromGraphPredicate } from '../lineage/obligation-predicates.js';
|
|
67
|
+
import { pastDecisionApplies } from './triage-memory.js';
|
|
67
68
|
|
|
68
69
|
// Fix-round item 4b: this renderer had `sanitizeForTerminal` (injection
|
|
69
70
|
// safety) but never honoured `--pseudonymize-authors` at all — an operator
|
|
@@ -597,7 +598,7 @@ export function evaluateFramework(scanRoot, fw, scan) {
|
|
|
597
598
|
.flatMap(k => families.get(k) || []);
|
|
598
599
|
const scoped = subfam ? candidates.filter(f => !f.subfamily || f.subfamily === subfam) : candidates;
|
|
599
600
|
const minRank = SEVERITY_RANK[minSeverity];
|
|
600
|
-
const open = scoped.filter(f => !f.intentSuppressed && !f.pastDecision && (SEVERITY_RANK[f.severity] ?? 0) >= minRank);
|
|
601
|
+
const open = scoped.filter(f => !f.intentSuppressed && !pastDecisionApplies(f.pastDecision) && (SEVERITY_RANK[f.severity] ?? 0) >= minRank);
|
|
601
602
|
if (open.length) {
|
|
602
603
|
allCleared = false;
|
|
603
604
|
contributingFindings.push(...open);
|
|
@@ -74,14 +74,14 @@ export function buildDetectionIR({ finding, proof = null, allowUnproven = false
|
|
|
74
74
|
|
|
75
75
|
let parameter = null;
|
|
76
76
|
if (attack?.parameter) { parameter = { name: attack.parameter, location: attack.location }; facts.parameter = { source: 'reproducer', ref: 'attack.parameter' }; }
|
|
77
|
-
else if (
|
|
77
|
+
else if (def.matcher !== 'behavior') limitations.push('no vulnerable parameter is known, so the payload test is applied to every query and body value of the endpoint');
|
|
78
78
|
|
|
79
79
|
let sink = null;
|
|
80
80
|
if (attack?.sink) { sink = attack.sink; facts.sink = { source: 'reproducer', ref: 'attack.sink' }; }
|
|
81
81
|
const affectedOperation = { sink, file: finding.file ? String(finding.file) : null, line: Number.isInteger(finding.line) ? finding.line : null };
|
|
82
82
|
|
|
83
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 ||
|
|
84
|
+
(grounding === 'exploit-confirmed' ? 0.9 : grounding === 'reproduced' ? 0.7 : 0.4) - (endpoint ? 0 : 0.2) - (parameter || def.matcher === 'behavior' ? 0 : 0.1)));
|
|
85
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
86
|
|
|
87
87
|
const ir = {
|