@clear-capabilities/agentic-security-scanner 0.153.2 → 0.155.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 +47 -0
- package/bin/agentic-security.js +131 -19
- package/dist/1444.index.js +64 -19
- package/dist/1905.index.js +46 -3
- package/dist/1919.index.js +445 -0
- package/dist/2862.index.js +325 -0
- package/dist/3499.index.js +5 -1
- package/dist/3531.index.js +261 -0
- package/dist/3596.index.js +103 -0
- package/dist/4113.index.js +15 -3
- package/dist/4171.index.js +319 -0
- package/dist/5637.index.js +10 -4
- package/dist/6359.index.js +433 -0
- package/dist/7838.index.js +15 -3
- package/dist/8551.index.js +1192 -0
- package/dist/8587.index.js +410 -0
- package/dist/8714.index.js +98 -0
- package/dist/8798.index.js +218 -0
- package/dist/9220.index.js +35 -3
- package/dist/9560.index.js +26 -3
- package/dist/agentic-security.mjs +51 -51
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +20 -3
- package/src/dataflow/CLAUDE.md +9 -0
- package/src/dataflow/catalog-haskell.js +37 -0
- package/src/dataflow/catalog.js +4 -0
- package/src/dataflow/engine.js +33 -3
- package/src/dataflow/privacy-catalog-haskell.js +50 -0
- package/src/dataflow/privacy-catalog.js +5 -1
- package/src/discovery/CLAUDE.md +6 -0
- package/src/discovery/index.js +12 -3
- package/src/discovery/lenses.js +4 -1
- package/src/discovery/partition.js +28 -0
- package/src/egress/redact.js +8 -0
- package/src/engine.js +125 -14
- package/src/ir/index.js +39 -2
- package/src/language/CLAUDE.md +74 -0
- package/src/language/accuracy.js +90 -0
- package/src/language/aibom.js +307 -0
- package/src/language/assurance.js +193 -0
- package/src/language/bom-validate.js +227 -0
- package/src/language/bom.js +236 -0
- package/src/language/bridges.js +392 -0
- package/src/language/compliance-map.js +61 -0
- package/src/language/context.js +199 -0
- package/src/language/contracts.js +339 -0
- package/src/language/discovery.js +359 -0
- package/src/language/engine-pass.js +120 -0
- package/src/language/fix-lifecycle.js +120 -0
- package/src/language/haskell-adapter.js +51 -0
- package/src/language/haskell-disclosure.js +170 -0
- package/src/language/haskell-findings.js +44 -0
- package/src/language/haskell-fix.js +409 -0
- package/src/language/haskell-grammar.js +78 -0
- package/src/language/haskell-guards.js +382 -0
- package/src/language/haskell-ir.js +1377 -0
- package/src/language/haskell-llm.js +128 -0
- package/src/language/haskell-manifests.js +1290 -0
- package/src/language/haskell-models.js +278 -0
- package/src/language/haskell-parser.js +1073 -0
- package/src/language/haskell-resolved-graph.js +414 -0
- package/src/language/haskell-sca.js +387 -0
- package/src/language/haskell-security-rules.js +332 -0
- package/src/language/haskell-supply.js +140 -0
- package/src/language/haskell-sweep.js +85 -0
- package/src/language/haskell-syntax.js +895 -0
- package/src/language/haskell-web.js +749 -0
- package/src/language/nix-adapter.js +42 -0
- package/src/language/nix-agents.js +155 -0
- package/src/language/nix-build-trust.js +790 -0
- package/src/language/nix-closure.js +331 -0
- package/src/language/nix-eval-isolation.js +274 -0
- package/src/language/nix-fix.js +268 -0
- package/src/language/nix-grammar.js +97 -0
- package/src/language/nix-inventory.js +379 -0
- package/src/language/nix-ir.js +642 -0
- package/src/language/nix-parser.js +908 -0
- package/src/language/nix-privacy.js +114 -0
- package/src/language/nix-sca.js +358 -0
- package/src/language/nix-script-taint.js +885 -0
- package/src/language/nix-secrets.js +396 -0
- package/src/language/nixos-hardening.js +564 -0
- package/src/language/nixos-module-resolver.js +595 -0
- package/src/language/nixos-option-catalog.js +96 -0
- package/src/language/pragma.js +150 -0
- package/src/language/resolved-pass.js +174 -0
- package/src/language/secrets.js +323 -0
- package/src/language/state-artifacts.js +78 -0
- package/src/language/support-registry.js +289 -0
- package/src/language/trusted-inputs.js +38 -0
- package/src/language/witness.js +185 -0
- package/src/lineage/CLAUDE.md +8 -0
- package/src/lineage/coverage.js +2 -0
- package/src/lineage/cross-repo-link.js +22 -0
- package/src/lineage/graph-builder.js +5 -1
- package/src/lineage/haskell-view.js +319 -0
- package/src/lineage/index.js +8 -1
- package/src/lineage/nix-view.js +198 -0
- package/src/lineage/sink-registry.js +10 -1
- package/src/lineage/source-registry.js +3 -0
- package/src/lineage/transform-catalog.js +12 -0
- package/src/llm-validator/index.js +16 -2
- package/src/lsp/server.js +69 -9
- package/src/mcp/CLAUDE.md +7 -0
- package/src/mcp/tools.js +26 -3
- package/src/pipeline/coverage-ledger.js +0 -0
- package/src/pipeline/scan-health.js +19 -1
- package/src/posture/accuracy-scorecard.js +51 -0
- package/src/posture/aibom.js +61 -3
- package/src/posture/artifact-registry.js +12 -0
- package/src/posture/auditor-walkthrough.js +41 -0
- package/src/posture/clustering.js +2 -2
- package/src/posture/evidence-bundle.js +7 -1
- package/src/posture/execution-proof.js +15 -0
- package/src/posture/falsification.js +7 -0
- package/src/posture/family-resolve.js +7 -0
- package/src/posture/fix-history.js +40 -0
- package/src/posture/fix-verify.js +15 -3
- package/src/posture/provenance/git-evidence.js +24 -0
- package/src/posture/provenance/origin-resolver.js +14 -2
- package/src/posture/provenance/predicate-replay.js +11 -2
- package/src/posture/provenance/sca-origin.js +20 -3
- package/src/posture/sbom-diff.js +7 -6
- package/src/posture/sbom.js +85 -32
- package/src/posture/sca-policy.js +10 -1
- package/src/posture/sca-verdict.js +8 -1
- package/src/posture/secret-history.js +62 -18
- package/src/posture/stable-id.js +21 -2
- package/src/posture/state-dir.js +21 -0
- package/src/posture/threat-model-auto.js +4 -4
- package/src/posture/triage.js +18 -1
- package/src/posture/watch-mode.js +34 -3
- package/src/pr-delta.js +9 -4
- package/src/report/index.js +35 -9
- package/src/runScan.js +56 -5
- package/src/sast/pipeline.js +34 -1
|
@@ -61,6 +61,7 @@
|
|
|
61
61
|
// Cache key includes the prompt template version + model id, so any change
|
|
62
62
|
// to the hardened prompt invalidates the cache.
|
|
63
63
|
|
|
64
|
+
import { languageClosureDigest, languagePromptExtras } from '../language/context.js';
|
|
64
65
|
import * as fs from 'node:fs';
|
|
65
66
|
import * as path from 'node:path';
|
|
66
67
|
import * as crypto from 'node:crypto';
|
|
@@ -288,6 +289,12 @@ function fileHashOf(fileContents, file) {
|
|
|
288
289
|
if (!file) return '';
|
|
289
290
|
const c = fileContents?.[file];
|
|
290
291
|
if (!c) return '';
|
|
292
|
+
// Haskell/Nix: a model verdict about one file depends on the modules it imports and the manifests around it, so the
|
|
293
|
+
// key covers that closure. Otherwise a cached `reject` would survive a change to the very module that made the
|
|
294
|
+
// finding true.
|
|
295
|
+
if (/\.(?:l?hs|hs-boot|hsc|nix)$/i.test(file)) {
|
|
296
|
+
try { return languageClosureDigest(fileContents, file); } catch { /* fall back to the file alone */ }
|
|
297
|
+
}
|
|
291
298
|
return crypto.createHash('sha256').update(c).digest('hex').slice(0, 32);
|
|
292
299
|
}
|
|
293
300
|
|
|
@@ -319,6 +326,10 @@ function renderPrompt(finding, fileContents, challenge, nonce, scanRoot) {
|
|
|
319
326
|
: `${finding.file}:${finding.line} [single-point detection, no cross-file path]`;
|
|
320
327
|
// Defensive: strip the delimiter literally from the untrusted excerpt so
|
|
321
328
|
// an attacker can't close it early by embedding our token.
|
|
329
|
+
// Related files on the finding's own evidence chain (Haskell/Nix), under the same untrusted-excerpt rules.
|
|
330
|
+
try {
|
|
331
|
+
for (const x of languagePromptExtras(finding, fileContents)) context += `${context ? '\n' : ''}--- related excerpt: ${x.file} ---\n${redactPayload({ text: x.text, filePath: x.file, scanRoot }).text}`;
|
|
332
|
+
} catch { /* extra context is advisory */ }
|
|
322
333
|
let sterileContext = String(context || '')
|
|
323
334
|
.replace(/BEGIN-UNTRUSTED-CODE-EXCERPT-[a-f0-9]+/gi, '[stripped-delimiter]')
|
|
324
335
|
.replace(/END-UNTRUSTED-CODE-EXCERPT-[a-f0-9]+/gi, '[stripped-delimiter]');
|
|
@@ -394,12 +405,15 @@ async function callEndpoint(endpoint, apiKey, model, prompt, preset = null, shap
|
|
|
394
405
|
else headers['Authorization'] = `Bearer ${apiKey}`;
|
|
395
406
|
}
|
|
396
407
|
try {
|
|
397
|
-
|
|
408
|
+
// A model that accepts the connection and never answers must not hold a scan open forever: every call is bounded.
|
|
409
|
+
// The result is an UNAVAILABLE finding (kept, unvalidated), never a silent pass.
|
|
410
|
+
const ms = Number(process.env.AGENTIC_SECURITY_LLM_TIMEOUT_MS) > 0 ? Number(process.env.AGENTIC_SECURITY_LLM_TIMEOUT_MS) : 60_000;
|
|
411
|
+
const r = await fetch(endpoint, { method: 'POST', headers, body: JSON.stringify(body), signal: AbortSignal.timeout(ms) });
|
|
398
412
|
if (!r.ok) return { ok: false, error: `HTTP ${r.status}` };
|
|
399
413
|
const j = await r.json().catch(() => null);
|
|
400
414
|
return { ok: true, text: String(extractText(j) || ''), usage: extractUsage ? extractUsage(j) : null };
|
|
401
415
|
} catch (e) {
|
|
402
|
-
return { ok: false, error: e.message };
|
|
416
|
+
return { ok: false, error: e && e.name === 'TimeoutError' ? 'timeout' : e.message };
|
|
403
417
|
}
|
|
404
418
|
}
|
|
405
419
|
|
package/src/lsp/server.js
CHANGED
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
// Wire-format: vscode-jsonrpc framing (Content-Length headers). Stateless
|
|
14
14
|
// per file — no incremental analysis yet.
|
|
15
15
|
|
|
16
|
+
import { isLanguageManifest } from '../language/discovery.js';
|
|
17
|
+
import { withLanguageContext, loadLanguageProject, languageOfFinding, languageFixPreview, minimalEdit } from '../language/context.js';
|
|
16
18
|
import * as fs from 'node:fs';
|
|
17
19
|
import * as path from 'node:path';
|
|
18
20
|
import * as readline from 'node:readline';
|
|
@@ -53,6 +55,21 @@ function sevToLsp(sev) {
|
|
|
53
55
|
}
|
|
54
56
|
}
|
|
55
57
|
|
|
58
|
+
// The most precise location a finding carries. Haskell and Nix findings keep their ORIGINAL span (0-based columns); a
|
|
59
|
+
// finding with only a line is reported across that line, as before.
|
|
60
|
+
function _rangeOf(f) {
|
|
61
|
+
const o = f && f.originalLocation;
|
|
62
|
+
if (o && Number.isInteger(o.startLine) && Number.isInteger(o.startColumn)) {
|
|
63
|
+
return { start: { line: Math.max(0, o.startLine - 1), character: o.startColumn }, end: { line: Math.max(0, (Number.isInteger(o.endLine) ? o.endLine : o.startLine) - 1), character: Number.isInteger(o.endColumn) ? o.endColumn : o.startColumn + 1 } };
|
|
64
|
+
}
|
|
65
|
+
if (o && Number.isInteger(o.line) && Number.isInteger(o.column)) {
|
|
66
|
+
const line = Math.max(0, o.line - 1);
|
|
67
|
+
return { start: { line, character: o.column }, end: { line, character: 200 } };
|
|
68
|
+
}
|
|
69
|
+
const line = Math.max(0, ((f && f.line) || 1) - 1);
|
|
70
|
+
return { start: { line, character: 0 }, end: { line, character: 200 } };
|
|
71
|
+
}
|
|
72
|
+
|
|
56
73
|
function findingToDiagnostic(f) {
|
|
57
74
|
const line = Math.max(0, (f.line || 1) - 1);
|
|
58
75
|
// Stage 6 correctness audit: this read f.remediation directly, but raw
|
|
@@ -63,13 +80,12 @@ function findingToDiagnostic(f) {
|
|
|
63
80
|
// report/index.js already established for this exact split (CMP-3).
|
|
64
81
|
const remediation = _remediationOf(f);
|
|
65
82
|
return {
|
|
66
|
-
range:
|
|
67
|
-
start: { line, character: 0 },
|
|
68
|
-
end: { line, character: 200 },
|
|
69
|
-
},
|
|
83
|
+
range: _rangeOf(f),
|
|
70
84
|
severity: sevToLsp(f.severity),
|
|
71
85
|
source: 'agentic-security',
|
|
72
86
|
code: f.cwe || f.family || 'finding',
|
|
87
|
+
// carried back by the client in codeAction requests, so a fix is offered for exactly this finding
|
|
88
|
+
data: { id: f.id || null, stableId: f.stableId || null, rule: f.rule || null, family: f.family || null, language: f.language || languageOfFinding(f) || null },
|
|
73
89
|
message: `${f.vuln || 'Security finding'}${remediation ? '\n\n' + remediation : ''}`.slice(0, 2000),
|
|
74
90
|
tags: [],
|
|
75
91
|
};
|
|
@@ -101,7 +117,10 @@ const DEP_BASE_NAMES = new Set([
|
|
|
101
117
|
'go.mod', 'Cargo.toml', 'Cargo.lock',
|
|
102
118
|
'pom.xml', 'build.gradle', 'build.gradle.kts',
|
|
103
119
|
]);
|
|
104
|
-
const DEP_EXT_RE = /\.(?:proto|graphql|gql|tf)$/i;
|
|
120
|
+
const DEP_EXT_RE = /\.(?:proto|graphql|gql|tf|cabal)$/i;
|
|
121
|
+
// Haskell and Nix project manifests and lock files (cabal.project*, stack.yaml*, package.yaml, flake.lock, ...) are
|
|
122
|
+
// recognised by the shared language discovery module, never by a second list here.
|
|
123
|
+
const _isLanguageDep = (base) => isLanguageManifest(base);
|
|
105
124
|
const DEP_NAME_RE = /(?:openapi|swagger)\.(?:ya?ml|json)$/i;
|
|
106
125
|
|
|
107
126
|
let _depCache = { rootDir: null, depFileContents: {} };
|
|
@@ -119,7 +138,7 @@ function _loadDepFileContents(rootDir) {
|
|
|
119
138
|
if (e.isDirectory()) { walk(full); continue; }
|
|
120
139
|
if (!e.isFile()) continue;
|
|
121
140
|
const base = e.name;
|
|
122
|
-
if (DEP_BASE_NAMES.has(base) || DEP_EXT_RE.test(base) || DEP_NAME_RE.test(base)) {
|
|
141
|
+
if (DEP_BASE_NAMES.has(base) || DEP_EXT_RE.test(base) || DEP_NAME_RE.test(base) || _isLanguageDep(base)) {
|
|
123
142
|
let stat;
|
|
124
143
|
try { stat = fs.statSync(full); } catch { continue; }
|
|
125
144
|
if (stat.size > 500_000) continue;
|
|
@@ -144,8 +163,11 @@ async function scanFile(uri) {
|
|
|
144
163
|
try {
|
|
145
164
|
const rel = path.relative(_rootDir, filePath);
|
|
146
165
|
const content = fs.readFileSync(filePath, 'utf8');
|
|
147
|
-
|
|
148
|
-
|
|
166
|
+
let fileContents = { [rel]: content };
|
|
167
|
+
let depFileContents = _loadDepFileContents(_rootDir);
|
|
168
|
+
// Haskell/Nix: scan the saved file together with the modules it imports and the ones that import it (bounded).
|
|
169
|
+
const lc = withLanguageContext(_rootDir, fileContents, depFileContents);
|
|
170
|
+
fileContents = lc.fileContents; depFileContents = lc.depFileContents;
|
|
149
171
|
// Premortem 4R-12 + 4R-15: reset the per-process custom-rules budget at
|
|
150
172
|
// the start of each LSP scan. Each save is a logical scan session; without
|
|
151
173
|
// the reset, a long-lived LSP server would accumulate budget across saves
|
|
@@ -220,12 +242,50 @@ function handleInitialize(params) {
|
|
|
220
242
|
save: { includeText: false },
|
|
221
243
|
},
|
|
222
244
|
diagnosticProvider: { interFileDependencies: true, workspaceDiagnostics: false },
|
|
245
|
+
// quick fixes for Haskell and Nix findings (preview edits from the verified language fixers)
|
|
246
|
+
codeActionProvider: { codeActionKinds: ['quickfix'], resolveProvider: false },
|
|
223
247
|
},
|
|
224
248
|
serverInfo: { name: SERVER_NAME, version: SERVER_VERSION },
|
|
225
249
|
};
|
|
226
250
|
}
|
|
227
251
|
|
|
252
|
+
/**
|
|
253
|
+
* textDocument/codeAction: a quick fix for each agentic-security diagnostic in range that a language fixer can repair.
|
|
254
|
+
* The edit is a PREVIEW the editor applies; nothing is written here. A finding with no safe fix gets a disabled
|
|
255
|
+
* action that says why, never a silent omission.
|
|
256
|
+
*/
|
|
257
|
+
async function codeActionsFor(params) {
|
|
258
|
+
const uri = params && params.textDocument && params.textDocument.uri;
|
|
259
|
+
const diags = ((params && params.context && params.context.diagnostics) || []).filter((d) => d && d.source === 'agentic-security' && d.data && d.data.id);
|
|
260
|
+
const stored = (uri && _diagnosticsByUri.get(uri)) || [];
|
|
261
|
+
if (!diags.length || !stored.length) return [];
|
|
262
|
+
let files = null;
|
|
263
|
+
const actions = [];
|
|
264
|
+
for (const d of diags) {
|
|
265
|
+
const f = stored.find((x) => x && x.id === d.data.id);
|
|
266
|
+
if (!f || !languageOfFinding(f)) continue;
|
|
267
|
+
if (!files) files = loadLanguageProject(_rootDir).files;
|
|
268
|
+
let prev;
|
|
269
|
+
try { prev = await languageFixPreview(f, files); } catch (e) { prev = { ok: false, reason: String((e && e.message) || e) }; }
|
|
270
|
+
if (prev && prev.ok && prev.file) {
|
|
271
|
+
const target = pathToUri(path.join(_rootDir, prev.file));
|
|
272
|
+
actions.push({
|
|
273
|
+
title: `Fix${prev.label ? ` (${prev.label})` : ''}: ${f.vuln || f.rule || 'finding'}`,
|
|
274
|
+
kind: 'quickfix', diagnostics: [d], isPreferred: false,
|
|
275
|
+
edit: { changes: { [target]: [minimalEdit(prev.before, prev.after)] } },
|
|
276
|
+
data: { stableId: f.stableId || null, verified: { syntax: true, rescan: true } },
|
|
277
|
+
});
|
|
278
|
+
} else {
|
|
279
|
+
actions.push({ title: `No automatic fix: ${f.vuln || f.rule || 'finding'}`, kind: 'quickfix', diagnostics: [d], disabled: { reason: (prev && (prev.reason || prev.status)) || 'no deterministic fix exists for this finding' } });
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return actions;
|
|
283
|
+
}
|
|
284
|
+
|
|
228
285
|
async function handleMessage(msg) {
|
|
286
|
+
if (msg.method === 'textDocument/codeAction') {
|
|
287
|
+
return { id: msg.id, result: await codeActionsFor(msg.params || {}) };
|
|
288
|
+
}
|
|
229
289
|
if (msg.method === 'initialize') {
|
|
230
290
|
return { id: msg.id, result: handleInitialize(msg.params || {}) };
|
|
231
291
|
}
|
|
@@ -254,7 +314,7 @@ async function handleMessage(msg) {
|
|
|
254
314
|
const savedPath = uriToPath(uri);
|
|
255
315
|
if (savedPath && _depCache.rootDir === _rootDir) {
|
|
256
316
|
const base = path.basename(savedPath);
|
|
257
|
-
if (DEP_BASE_NAMES.has(base) || DEP_EXT_RE.test(base) || DEP_NAME_RE.test(base)) {
|
|
317
|
+
if (DEP_BASE_NAMES.has(base) || DEP_EXT_RE.test(base) || DEP_NAME_RE.test(base) || _isLanguageDep(base)) {
|
|
258
318
|
try {
|
|
259
319
|
const rel = path.relative(_rootDir, savedPath);
|
|
260
320
|
const st = fs.statSync(savedPath);
|
package/src/mcp/CLAUDE.md
CHANGED
|
@@ -65,3 +65,10 @@ MCP server. JSON-RPC 2.0 over NDJSON on stdin/stdout. Bin entry `../../bin/agent
|
|
|
65
65
|
- **Lifecycle.** `_codeFingerprint()` reads source files at module-load time. New files added to the MCP source set won't be in the fingerprint until they're added to the `files = […]` array in `server.js`.
|
|
66
66
|
- **Audit log.** The chain hashes plain JSON lines; a full-file rewrite is not detectable without a remote sink. Acknowledged limitation.
|
|
67
67
|
- **Concurrency.** `stdio.js`'s `'data'` handler is async; concurrent `apply_fix` calls can race on `fix-history/`. Today benign because fixed-fix-history is idempotent on retry, but a future stateful tool needs serialization.
|
|
68
|
+
|
|
69
|
+
## Haskell and Nix context
|
|
70
|
+
|
|
71
|
+
`scan_diff`, `verify_fix` and `synthesize_fix` add the import closure of a Haskell or Nix file and the manifests as context
|
|
72
|
+
(`language/context.js` `withLanguageContext`, bounded in files and bytes, read-only) and still report only the files asked
|
|
73
|
+
about. The tool that previews a fix for these files uses the language fixers; `apply_fix` is unchanged (its generic confined-write
|
|
74
|
+
path), and the language-aware apply with backup and `undo` is the CLI's `fix --apply`.
|
package/src/mcp/tools.js
CHANGED
|
@@ -371,6 +371,10 @@ export const scan_diff = {
|
|
|
371
371
|
const abs = files.map(f => _confine(sessionRoot, f, 'files[]'));
|
|
372
372
|
|
|
373
373
|
const fileContents = {};
|
|
374
|
+
// `_confine` returns real paths; the session root may itself be a symlink (macOS /tmp, /var), so keys are made
|
|
375
|
+
// relative to the REAL root. A key built from the unresolved root climbs out of the tree and no finding ever
|
|
376
|
+
// matches the file it was asked about.
|
|
377
|
+
const rootReal = fs.realpathSync(path.resolve(sessionRoot));
|
|
374
378
|
let totalBytes = 0;
|
|
375
379
|
for (const a of abs) {
|
|
376
380
|
let stat;
|
|
@@ -383,7 +387,7 @@ export const scan_diff = {
|
|
|
383
387
|
}
|
|
384
388
|
let content;
|
|
385
389
|
try { content = fs.readFileSync(a, 'utf8'); } catch { continue; }
|
|
386
|
-
const rel = path.relative(
|
|
390
|
+
const rel = path.relative(rootReal, a).replace(/\\/g, '/');
|
|
387
391
|
fileContents[rel] = content;
|
|
388
392
|
}
|
|
389
393
|
|
|
@@ -403,9 +407,13 @@ export const scan_diff = {
|
|
|
403
407
|
// user's real project on every pre-write self-correction scan. Confirmed
|
|
404
408
|
// by direct execution before this fix (11 state artifacts written by a
|
|
405
409
|
// single scan_diff-shaped call).
|
|
406
|
-
|
|
407
|
-
|
|
410
|
+
// Haskell and Nix results depend on imported modules and manifests, so the files asked about are scanned together
|
|
411
|
+
// with their import closure (read from disk, bounded). Findings are still reported only for the requested files.
|
|
408
412
|
const wantSet = new Set(Object.keys(fileContents));
|
|
413
|
+
const { withLanguageContext } = await import('../language/context.js');
|
|
414
|
+
const lc = withLanguageContext(sessionRoot, fileContents, {});
|
|
415
|
+
const result = await withStateWritesDisabled(() =>
|
|
416
|
+
runScan(sessionRoot, { network: false, fileContents: lc.fileContents, depFileContents: lc.depFileContents, deep: true, deepInCi: true }));
|
|
409
417
|
const sevRank = { info: 0, low: 1, medium: 2, high: 3, critical: 4 };
|
|
410
418
|
const min = sevRank[severity] ?? 0;
|
|
411
419
|
// Stage 6 correctness audit (historical): this used to only read
|
|
@@ -1055,12 +1063,26 @@ export const synthesize_fix = {
|
|
|
1055
1063
|
// even a mis-attributed swap can't land a bad edit. No stored replacement,
|
|
1056
1064
|
// no per-finding bloat in last-scan.json.
|
|
1057
1065
|
let autofix = null;
|
|
1066
|
+
let languageFix = null;
|
|
1058
1067
|
if (!hasReplacement) {
|
|
1059
1068
|
try {
|
|
1060
1069
|
const abs = _confine(ctx.sessionRoot, f.file, 'finding.file');
|
|
1061
1070
|
const det = synthesizeDeterministicPatch(f, fs.readFileSync(abs, 'utf8'));
|
|
1062
1071
|
if (det) autofix = { deterministic: true, ruleId: det.ruleId, patch: det.patch };
|
|
1063
1072
|
} catch { /* best-effort — no file / no rule → no autofix */ }
|
|
1073
|
+
// Haskell and Nix: a read-only preview from the language fixer, with the verification gates' verdict and the
|
|
1074
|
+
// FULL / MITIGATION / WORKAROUND tier. It writes nothing; apply_fix re-verifies before anything lands.
|
|
1075
|
+
if (!autofix) {
|
|
1076
|
+
try {
|
|
1077
|
+
const lc = await import('../language/context.js');
|
|
1078
|
+
if (lc.languageOfFinding(f)) {
|
|
1079
|
+
const proj = lc.loadLanguageProject(ctx.sessionRoot);
|
|
1080
|
+
const prev = await lc.languageFixPreview(f, proj.files);
|
|
1081
|
+
languageFix = { status: prev.status, ok: prev.ok, label: prev.label || null, tier: prev.tier || null, reason: prev.reason || null, diff: prev.diff || null, explanation: prev.explanation || null, consequences: prev.consequences || [] };
|
|
1082
|
+
if (prev.ok) autofix = { deterministic: true, ruleId: f.rule || f.family || null, patch: prev.after, file: prev.file, label: prev.label || null, verified: true };
|
|
1083
|
+
}
|
|
1084
|
+
} catch { /* best-effort: the preview is advisory */ }
|
|
1085
|
+
}
|
|
1064
1086
|
}
|
|
1065
1087
|
// Premortem #2: `replacement` is a *patch* (the code we'll write to disk),
|
|
1066
1088
|
// not a finding excerpt. Running it through redactString silently corrupts
|
|
@@ -1079,6 +1101,7 @@ export const synthesize_fix = {
|
|
|
1079
1101
|
replacement: hasReplacement ? fix.replacement : null,
|
|
1080
1102
|
template: fix.code || null,
|
|
1081
1103
|
autofix,
|
|
1104
|
+
languageFix,
|
|
1082
1105
|
// #15 — the regression test the scan annotator already generated for this
|
|
1083
1106
|
// finding (present when a PoC was built). Surfaced here so the fix flow
|
|
1084
1107
|
// writes the test alongside the patch; fix-verify-loop then runs it, so an
|
|
Binary file
|
|
@@ -42,7 +42,7 @@ export const SCAN_HEALTH_SCHEMA_VERSION = 1;
|
|
|
42
42
|
* never a fabricated all-zero summary.
|
|
43
43
|
* @returns {object} scanHealth per PRD §10.3, additive fields only.
|
|
44
44
|
*/
|
|
45
|
-
export function computeScanHealth({ scanMeta = null, annotatorErrors = [], engineErrors = null, deepStatus = null, analyzerCoverage = null, lineageStatus = null } = {}) {
|
|
45
|
+
export function computeScanHealth({ scanMeta = null, annotatorErrors = [], engineErrors = null, deepStatus = null, analyzerCoverage = null, lineageStatus = null, languageCoverage = null, supplyChain = null } = {}) {
|
|
46
46
|
const conditions = [];
|
|
47
47
|
const safeAnnotatorErrors = Array.isArray(annotatorErrors) ? annotatorErrors : [];
|
|
48
48
|
const filesTimedOut = Number(scanMeta?.filesTimedOut) || 0;
|
|
@@ -84,11 +84,29 @@ export function computeScanHealth({ scanMeta = null, annotatorErrors = [], engin
|
|
|
84
84
|
conditions.push(`${analyzerCoverage.failed} analyzer(s) threw on at least one file`);
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
// CORE-003: Haskell/Nix coverage outcomes (missing grammar, adapter
|
|
88
|
+
// exception, timeout, unresolved branch) from language/contracts.js.
|
|
89
|
+
// Additive: absent input leaves the output shape and conditions untouched.
|
|
90
|
+
if (languageCoverage && Array.isArray(languageCoverage.conditions)) {
|
|
91
|
+
conditions.push(...languageCoverage.conditions);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// NIX-005: build-trust findings ride in the ordinary supplyChain bucket. A finding that
|
|
95
|
+
// rests on an unresolved branch or a partial module graph is an analysis gap, so it is
|
|
96
|
+
// surfaced here rather than counted as a settled result.
|
|
97
|
+
const trustFindings = Array.isArray(supplyChain) ? supplyChain.filter((f) => f && f.type === 'nix_build_trust') : [];
|
|
98
|
+
const trustUncertain = trustFindings.filter((f) => Array.isArray(f.uncertainty) && f.uncertainty.length > 0).length;
|
|
99
|
+
if (trustUncertain > 0) {
|
|
100
|
+
conditions.push(`${trustUncertain} Nix build-trust finding(s) rest on an unresolved branch or partial module graph`);
|
|
101
|
+
}
|
|
102
|
+
|
|
87
103
|
const status = conditions.length > 0 ? 'partial' : 'complete';
|
|
88
104
|
|
|
89
105
|
return {
|
|
90
106
|
schemaVersion: SCAN_HEALTH_SCHEMA_VERSION,
|
|
91
107
|
status,
|
|
108
|
+
...(trustFindings.length ? { buildTrust: { findings: trustFindings.length, uncertain: trustUncertain } } : {}),
|
|
109
|
+
...(languageCoverage ? { languageCoverage: { totals: languageCoverage.totals ?? null, byKind: languageCoverage.byKind ?? null, ...(languageCoverage.limitations ? { limitations: languageCoverage.limitations } : {}), ...(languageCoverage.capabilities ? { capabilities: languageCoverage.capabilities } : {}), ...(languageCoverage.optionalModes ? { optionalModes: languageCoverage.optionalModes } : {}) } } : {}),
|
|
92
110
|
files: {
|
|
93
111
|
expected: scanMeta?.checkpoint?.total ?? null,
|
|
94
112
|
scanned: scanMeta?.filesScanned ?? null,
|
|
@@ -205,6 +205,23 @@ export function buildScorecard(inputs) {
|
|
|
205
205
|
corpusBaseline: committed.corpusBaseline
|
|
206
206
|
? { source: 'bench/cve-replay/corpus-baseline.json', generatedAt: committed.corpusBaseline.generatedAt, total: committed.corpusBaseline.total, passing: committed.corpusBaseline.passing }
|
|
207
207
|
: null,
|
|
208
|
+
// Haskell and Nix support (HASKELL_NIXOS_FULL_CAPABILITY_PRD.md DOC-002): the committed support registry, passed through with
|
|
209
|
+
// its denominators, layers, corpus revision, date and tool availability. Read, never re-measured here.
|
|
210
|
+
languageSupport: committed.languageSupport
|
|
211
|
+
? {
|
|
212
|
+
source: 'docs/language-support.json',
|
|
213
|
+
generatedAt: committed.languageSupport.generatedAt || null,
|
|
214
|
+
node: committed.languageSupport.node || null,
|
|
215
|
+
tools: committed.languageSupport.tools || null,
|
|
216
|
+
targets: committed.languageSupport.targets || null,
|
|
217
|
+
limits: committed.languageSupport.limits || [],
|
|
218
|
+
languages: Object.fromEntries(Object.entries(committed.languageSupport.languages || {}).map(([k, v]) => [k, {
|
|
219
|
+
frozen: v.frozen || null, measuredOn: v.measuredOn || null,
|
|
220
|
+
rows: Object.values(v.rows || {}).map((r) => ({ capability: r.capability, status: r.status, reasons: r.reasons || [], evidence: r.evidence ? { metric: r.evidence.metric || null, layer: r.evidence.layer || null, tp: r.evidence.tp ?? null, fp: r.evidence.fp ?? null, fn: r.evidence.fn ?? null, tn: r.evidence.tn ?? null, precision: r.evidence.precision ?? null, recall: r.evidence.recall ?? null, f1: r.evidence.f1 ?? null, cases: r.evidence.cases ?? null, familyCount: r.evidence.families ? Object.keys(r.evidence.families).length : null } : null })),
|
|
221
|
+
unknownOutcomes: v.unknownOutcomes || null, metamorphic: v.metamorphic || null,
|
|
222
|
+
}])),
|
|
223
|
+
}
|
|
224
|
+
: null,
|
|
208
225
|
independent: committed.independent
|
|
209
226
|
? {
|
|
210
227
|
source: 'bench/independent/RESULT.json',
|
|
@@ -621,6 +638,40 @@ export function renderScorecardMarkdown(m) {
|
|
|
621
638
|
}
|
|
622
639
|
}
|
|
623
640
|
|
|
641
|
+
const ls = m.committedInputs.languageSupport;
|
|
642
|
+
if (ls) {
|
|
643
|
+
L.push('## Haskell and Nix/NixOS support');
|
|
644
|
+
L.push('');
|
|
645
|
+
L.push(`*Committed artifact:* \`${ls.source}\`, measured ${ls.generatedAt || 'unknown date'} on node ${ls.node || '?'}. It is read here, not re-measured by this command.`);
|
|
646
|
+
L.push('');
|
|
647
|
+
L.push('Every row is **supported** only from passing evidence of its own metric kind. Layers are scored independently (the taint layer');
|
|
648
|
+
L.push('is the injection families, the SAST layer is every other family) and a finding of a different family inside a case is counted');
|
|
649
|
+
L.push('separately (family-scoped scoring); the strict precision that counts it is stored in the registry. Denominators are cases, with');
|
|
650
|
+
L.push('TP/FP/FN/TN shown; a row without a denominator carries a different kind of evidence (parse counts, fix gates, manifests).');
|
|
651
|
+
L.push('');
|
|
652
|
+
for (const [lang, v] of Object.entries(ls.languages)) {
|
|
653
|
+
L.push(`### ${lang === 'haskell' ? 'Haskell' : 'Nix and NixOS'}`);
|
|
654
|
+
L.push('');
|
|
655
|
+
L.push('| Capability | Status | Layer | Cases | TP | FP | FN | TN | Precision | Recall | F1 | Families |');
|
|
656
|
+
L.push('| --- | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |');
|
|
657
|
+
const pct = (x) => (typeof x === 'number' ? `${(x * 100).toFixed(1)}%` : ', ');
|
|
658
|
+
for (const r of v.rows) {
|
|
659
|
+
const e = r.evidence;
|
|
660
|
+
L.push(`| ${r.capability} | ${r.status} | ${e && e.layer ? e.layer : '—'} | ${e && e.cases != null ? e.cases : '—'} | ${e && e.tp != null ? e.tp : '—'} | ${e && e.fp != null ? e.fp : '—'} | ${e && e.fn != null ? e.fn : '—'} | ${e && e.tn != null ? e.tn : '—'} | ${e ? pct(e.precision) : '—'} | ${e ? pct(e.recall) : '—'} | ${e ? pct(e.f1) : '—'} | ${e && e.familyCount != null ? e.familyCount : '—'} |`);
|
|
661
|
+
}
|
|
662
|
+
L.push('');
|
|
663
|
+
const blocked = v.rows.filter((r) => r.status !== 'supported');
|
|
664
|
+
if (blocked.length) { L.push(`Not supported here: ${blocked.map((r) => `${r.capability} (${r.status}: ${(r.reasons[0] || '').slice(0, 140)})`).join('; ')}.`); L.push(''); }
|
|
665
|
+
if (v.unknownOutcomes) L.push(`Unknown and unmodelled cases: ${JSON.stringify(v.unknownOutcomes)}.`);
|
|
666
|
+
if (v.metamorphic) L.push(`Metamorphic pairs: ${JSON.stringify(v.metamorphic)}.`);
|
|
667
|
+
L.push('');
|
|
668
|
+
}
|
|
669
|
+
if (ls.tools) L.push(`Tools present where this was measured: ${Object.entries(ls.tools).map(([k, x]) => `${k}=${x === false ? 'absent' : x}`).join(', ')}.`);
|
|
670
|
+
L.push('');
|
|
671
|
+
for (const lim of ls.limits) L.push(`- ${lim}`);
|
|
672
|
+
L.push('');
|
|
673
|
+
}
|
|
674
|
+
|
|
624
675
|
const ind = m.committedInputs.independent;
|
|
625
676
|
if (ind && ind.overall) {
|
|
626
677
|
L.push('## Independent evaluation population — the number that matters');
|
package/src/posture/aibom.js
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
// scan.components. Extraction precision is verified by a smoke test against
|
|
21
21
|
// a labelled fixture set.
|
|
22
22
|
|
|
23
|
+
import { extractLanguageAI } from '../language/aibom.js';
|
|
23
24
|
import * as crypto from 'node:crypto';
|
|
24
25
|
import { statePath, safeWriteState } from './state-dir.js';
|
|
25
26
|
|
|
@@ -199,6 +200,16 @@ export function buildAIBOM(scan, fileContents = {}, meta = {}) {
|
|
|
199
200
|
if (classes.includes('vector-store')) vectorStores.push({ ecosystem: c.ecosystem, name: c.name, version: c.version });
|
|
200
201
|
if (classes.includes('embedding-provider')) embeddings.push({ ecosystem: c.ecosystem, name: c.name, version: c.version });
|
|
201
202
|
}
|
|
203
|
+
// 4. Haskell and Nix (X-002): declared / installed / enabled / invoked are kept apart, evidence is a list,
|
|
204
|
+
// and anything source does not spell out is `unresolved` rather than guessed.
|
|
205
|
+
let lang = { models: [], endpoints: [], services: [], frameworks: [], vectorStores: [], embeddings: [], promptTemplates: [], unresolved: [], links: [], gaps: [] };
|
|
206
|
+
try { lang = extractLanguageAI({ ...(meta.manifests || {}), ...(fileContents || {}) }); } catch { /* best effort: the JS/Python inventory above stands */ }
|
|
207
|
+
const knownModel = new Set(models.map((m) => `${m.provider}:${m.modelId}`));
|
|
208
|
+
for (const m of lang.models) { const k = `${m.provider}:${m.modelId}`; if (!knownModel.has(k)) { knownModel.add(k); models.push({ ...m, file: (m.evidence && m.evidence[0] && m.evidence[0].file) || null, line: (m.evidence && m.evidence[0] && m.evidence[0].line) || null }); } }
|
|
209
|
+
promptTemplates.push(...lang.promptTemplates);
|
|
210
|
+
const knownFw = new Set(frameworks.map((f) => `${f.ecosystem}:${f.name}`));
|
|
211
|
+
for (const f of lang.frameworks) { const k = `${f.ecosystem}:${f.name}`; if (!knownFw.has(k)) { knownFw.add(k); frameworks.push(f); } }
|
|
212
|
+
vectorStores.push(...lang.vectorStores); embeddings.push(...lang.embeddings);
|
|
202
213
|
return {
|
|
203
214
|
aibomFormat: 'agentic-security AI-BOM',
|
|
204
215
|
version: '1',
|
|
@@ -221,6 +232,15 @@ export function buildAIBOM(scan, fileContents = {}, meta = {}) {
|
|
|
221
232
|
frameworks,
|
|
222
233
|
vectorStores,
|
|
223
234
|
embeddings,
|
|
235
|
+
endpoints: lang.endpoints,
|
|
236
|
+
services: lang.services,
|
|
237
|
+
unresolved: lang.unresolved,
|
|
238
|
+
links: lang.links,
|
|
239
|
+
limits: [
|
|
240
|
+
'an AI component being declared, installed or enabled does not show that a model is called or that data is sent to it',
|
|
241
|
+
'models, endpoints and providers that source does not spell out are listed under `unresolved`, not guessed',
|
|
242
|
+
...(lang.gaps || []).map((g) => g.detail || g.kind),
|
|
243
|
+
],
|
|
224
244
|
summary: {
|
|
225
245
|
totalModels: models.length,
|
|
226
246
|
totalProviders: new Set(models.map(m => m.provider)).size,
|
|
@@ -229,6 +249,10 @@ export function buildAIBOM(scan, fileContents = {}, meta = {}) {
|
|
|
229
249
|
promptTemplates: promptTemplates.length,
|
|
230
250
|
frameworks: frameworks.length,
|
|
231
251
|
vectorStores: vectorStores.length,
|
|
252
|
+
endpoints: lang.endpoints.length,
|
|
253
|
+
services: lang.services.length,
|
|
254
|
+
embeddings: embeddings.length,
|
|
255
|
+
unresolved: lang.unresolved.length,
|
|
232
256
|
},
|
|
233
257
|
};
|
|
234
258
|
}
|
|
@@ -297,6 +321,30 @@ export function aibomToMarkdown(aibom) {
|
|
|
297
321
|
out.push('');
|
|
298
322
|
}
|
|
299
323
|
|
|
324
|
+
const row = (cells) => `| ${cells.map((c) => String(c ?? '—').replace(/\|/g, '\\|').replace(/\n/g, ' ')).join(' | ')} |`;
|
|
325
|
+
if ((aibom.endpoints || []).length) {
|
|
326
|
+
out.push('## Endpoints'); out.push(''); out.push('| Provider | Purpose | URL (redacted) | Evidence |'); out.push('|---|---|---|---|');
|
|
327
|
+
for (const e of aibom.endpoints) out.push(row([e.provider, e.purpose, e.url, (e.evidence || []).map((x) => `${x.file}:${x.line}`).join(', ')]));
|
|
328
|
+
out.push('');
|
|
329
|
+
}
|
|
330
|
+
if ((aibom.services || []).length) {
|
|
331
|
+
out.push('## Services'); out.push(''); out.push('| Service | Kind | Status | Evidence |'); out.push('|---|---|---|---|');
|
|
332
|
+
for (const x of aibom.services) out.push(row([x.name, x.kind, x.status, (x.evidence || []).map((e) => `${e.file}:${e.line}`).join(', ')]));
|
|
333
|
+
out.push('');
|
|
334
|
+
}
|
|
335
|
+
if ((aibom.embeddings || []).length) {
|
|
336
|
+
out.push('## Embeddings'); out.push(''); out.push('| Name | Provider | Status |'); out.push('|---|---|---|');
|
|
337
|
+
for (const e of aibom.embeddings) out.push(row([e.name, e.provider, e.status]));
|
|
338
|
+
out.push('');
|
|
339
|
+
}
|
|
340
|
+
if ((aibom.unresolved || []).length) {
|
|
341
|
+
out.push('## Unresolved'); out.push(''); out.push('| Kind | Location | Reason |'); out.push('|---|---|---|');
|
|
342
|
+
for (const u of aibom.unresolved) out.push(row([u.kind, `${u.file}:${u.line}`, u.reason]));
|
|
343
|
+
out.push('');
|
|
344
|
+
}
|
|
345
|
+
if ((aibom.limits || []).length) { out.push('## Limits'); out.push(''); for (const l of aibom.limits) out.push(`- ${l}`); out.push(''); }
|
|
346
|
+
out.push('> This document is the proprietary agentic-security AI-BOM, not a CycloneDX document. A CycloneDX 1.6 ML-BOM view is available separately.');
|
|
347
|
+
out.push('');
|
|
300
348
|
return out.join('\n');
|
|
301
349
|
}
|
|
302
350
|
|
|
@@ -324,9 +372,9 @@ export function toCycloneDXMLBOM(aibom, meta = {}) {
|
|
|
324
372
|
const models = (aibom && aibom.models) || [];
|
|
325
373
|
const components = models.map((m) => ({
|
|
326
374
|
type: 'machine-learning-model',
|
|
327
|
-
'bom-ref': `model:${m.provider || 'unknown'}/${m.name || 'unknown'}${m.version ? `@${m.version}` : ''}`,
|
|
328
|
-
name: m.name || 'unknown',
|
|
329
|
-
...(m.version ? { version: m.version } : {}),
|
|
375
|
+
'bom-ref': `model:${m.provider || 'unknown'}/${m.name || m.modelId || 'unknown'}${m.version || m.revision ? `@${m.version || m.revision}` : ''}`,
|
|
376
|
+
name: m.name || m.modelId || 'unknown',
|
|
377
|
+
...(m.version || m.revision ? { version: m.version || m.revision } : {}),
|
|
330
378
|
modelCard: {
|
|
331
379
|
modelParameters: {
|
|
332
380
|
...(m.provider ? { approach: { type: 'supervised' } } : {}),
|
|
@@ -359,6 +407,10 @@ export function toCycloneDXMLBOM(aibom, meta = {}) {
|
|
|
359
407
|
component: { type: 'application', name: 'scan-target', version: '1.0.0' },
|
|
360
408
|
},
|
|
361
409
|
components,
|
|
410
|
+
...(((aibom && aibom.services) || []).length || ((aibom && aibom.endpoints) || []).length ? { services: [
|
|
411
|
+
...(aibom.services || []).map((x) => ({ 'bom-ref': `service:${x.name}`, name: x.name, provider: { name: x.provider || 'unknown' }, description: `${x.kind} (${x.status})`, properties: [{ name: 'agentic-security:status', value: String(x.status) }, { name: 'agentic-security:egress', value: 'not-established' }] })),
|
|
412
|
+
...(aibom.endpoints || []).map((e) => ({ 'bom-ref': `endpoint:${e.url}`, name: `${e.provider} ${e.purpose}`, provider: { name: e.provider }, endpoints: [e.url], properties: [{ name: 'agentic-security:status', value: String(e.status) }] })),
|
|
413
|
+
] } : {}),
|
|
362
414
|
};
|
|
363
415
|
}
|
|
364
416
|
|
|
@@ -394,6 +446,12 @@ export function validateMLBOM(doc) {
|
|
|
394
446
|
`components[${i}] is a machine-learning-model but carries no modelCard — that is the whole ML-BOM extension`);
|
|
395
447
|
}
|
|
396
448
|
}
|
|
449
|
+
for (const [i, sv] of (Array.isArray(doc.services) ? doc.services : []).entries()) {
|
|
450
|
+
req(typeof sv.name === 'string' && sv.name, `services[${i}].name is required`);
|
|
451
|
+
if (sv.endpoints !== undefined) req(Array.isArray(sv.endpoints) && sv.endpoints.every((u) => typeof u === 'string' && !/\/\/[^/@]*:[^/@]*@/.test(u)), `services[${i}].endpoints must be URL strings without credentials`);
|
|
452
|
+
}
|
|
453
|
+
const refs = (Array.isArray(doc.components) ? doc.components : []).concat(Array.isArray(doc.services) ? doc.services : []).map((c) => c && c['bom-ref']).filter(Boolean);
|
|
454
|
+
req(new Set(refs).size === refs.length, 'bom-ref values must be unique');
|
|
397
455
|
return { ok: errors.length === 0, errors, checked: 'structural (required fields + ML-BOM component shape), NOT full JSON-Schema validation' };
|
|
398
456
|
}
|
|
399
457
|
|
|
@@ -95,6 +95,16 @@ export const ARTIFACT_REGISTRY = [
|
|
|
95
95
|
// clears by default, same as last-scan.json itself.
|
|
96
96
|
{ name: 'lineage-graph.json', kind: 'file', classification: 'generated', retentionClass: 'scan' },
|
|
97
97
|
{ name: 'lineage-graph.json.sig', kind: 'file', classification: 'generated', retentionClass: 'scan' },
|
|
98
|
+
// X-016: Haskell/Nix analysis metadata (capability status, parser/resolver ledger, limitations, input digests) and the
|
|
99
|
+
// language BOM, written by src/language/state-artifacts.js beside last-scan.json. Paths and digests only, never source
|
|
100
|
+
// text. Scan-class retention for the metadata; the BOM is evidence.
|
|
101
|
+
{ name: 'language-analysis.json', kind: 'file', classification: 'generated', retentionClass: 'scan', source: 'src/language/state-artifacts.js (persistLanguageArtifacts)' },
|
|
102
|
+
{ name: 'language-analysis.json.sig', kind: 'file', classification: 'generated', retentionClass: 'scan', source: 'src/language/state-artifacts.js (persistLanguageArtifacts)' },
|
|
103
|
+
{ name: 'language-bom.json', kind: 'file', classification: 'generated', retentionClass: 'evidence', source: 'src/language/state-artifacts.js (persistLanguageArtifacts)' },
|
|
104
|
+
{ name: 'language-bom.json.sig', kind: 'file', classification: 'generated', retentionClass: 'evidence', source: 'src/language/state-artifacts.js (persistLanguageArtifacts)' },
|
|
105
|
+
// X-016: pre-image of every file a Haskell/Nix fix rewrote (original text + meta), the undo source. It holds customer
|
|
106
|
+
// source, so it is a backup-class artifact and marked confidential for the encryption policy.
|
|
107
|
+
{ name: 'fix-backups', kind: 'dir', classification: 'generated', retentionClass: 'backup', confidential: true, source: 'src/language/fix-lifecycle.js (writeWithBackup)' },
|
|
98
108
|
// M4 deliverable #8 (FR-503 §14, DFG-022, sub-project 8a): the "Data-Flow
|
|
99
109
|
// Time Machine" foundation — one commit-keyed GraphSnapshot per scan,
|
|
100
110
|
// written by src/lineage/graph-snapshot.js's persistGraphSnapshot(),
|
|
@@ -237,6 +247,8 @@ export const ARTIFACT_REGISTRY = [
|
|
|
237
247
|
{ name: 'provenance-providers.yml', kind: 'file', classification: 'operator-config', note: 'Finding Provenance M3 §3.4 GitHub/GitLab provider enrichment opt-in ({token} or provider-scoped tokens) — read by posture/provenance/providers/config.js, never written by the scanner; env vars (AGENTIC_SECURITY_GITHUB_TOKEN/AGENTIC_SECURITY_GITLAB_TOKEN) take precedence when set' },
|
|
238
248
|
{ name: 'repo-lineage.json', kind: 'file', classification: 'operator-config', note: 'Finding Provenance M4 §4.2 cross-repository lineage declaration ({linkedFrom: {path, atCommit}}) — read by posture/provenance/repo-lineage.js, never written by the scanner; the linked path is verified as a real local git repo before use, no remote fetch' },
|
|
239
249
|
{ name: 'recipient-profiles.json', kind: 'file', classification: 'operator-config', note: 'FR-506 recipient/subprocessor governance profiles — hand- or agent-authored via `governance propose-edit`, never scanner-regenerable, so a routine reset must never delete it' },
|
|
250
|
+
{ name: 'hackage-advisories.json', kind: 'file', classification: 'operator-config', note: 'X-016: operator-supplied Hackage advisory snapshot read by language/haskell-supply.js; never written by the scanner, so `reset` preserves it' },
|
|
251
|
+
{ name: 'nix-advisories.json', kind: 'file', classification: 'operator-config', note: 'QA-005: operator-supplied Nix advisory snapshot read by language/resolved-pass.js; never written by the scanner, so `reset` preserves it' },
|
|
240
252
|
{ name: 'cross-repo-links.json', kind: 'file', classification: 'operator-config', note: 'M5 deliverable #8 (FR-304 "declared" half) — declared local<->remote node links between two independently-scanned repos, written via `federate declare --yes`, never scanner-regenerable, so a routine reset must never delete it' },
|
|
241
253
|
{ name: 'logic-claims.json', kind: 'file', classification: 'operator-config', note: 'authored by an external reviewing agent; engine.js only ever reads it (fs.readFileSync, never written)' },
|
|
242
254
|
{ name: 'current-intent.md', kind: 'file', classification: 'operator-config', note: 'developer-authored; no writer exists anywhere in src/ or bin/' },
|
|
@@ -43,11 +43,13 @@
|
|
|
43
43
|
// attestation" describes independent certification, not attestation).
|
|
44
44
|
|
|
45
45
|
import * as fs from 'node:fs';
|
|
46
|
+
import * as crypto from 'node:crypto';
|
|
46
47
|
import * as path from 'node:path';
|
|
47
48
|
|
|
48
49
|
import { statePath, stateWritesEnabled } from './state-dir.js';
|
|
49
50
|
import { EVIDENCE_GRADE_DISCLAIMER_SHORT } from './evidence-grade-wording.js';
|
|
50
51
|
import { COMPLIANCE_FAMILY_ALIAS, resolveFamilyKeys } from './family-resolve.js';
|
|
52
|
+
import { LANGUAGE_COMPLIANCE_FAMILIES, languageAnalysisGaps } from '../language/compliance-map.js';
|
|
51
53
|
import { strengthOfControl as _strengthOfControl } from './coverage-strength.js';
|
|
52
54
|
// FR-PROV-026: earliestOrigin.authorName below is untrusted git commit
|
|
53
55
|
// metadata. renderWalkthrough()'s output is console.log'd verbatim by
|
|
@@ -497,6 +499,7 @@ export function evaluateFramework(scanRoot, fw, scan) {
|
|
|
497
499
|
|| logicVulns.length > 0
|
|
498
500
|
|| supplyChain.length > 0;
|
|
499
501
|
|
|
502
|
+
const langIncomplete = languageAnalysisGaps(scan);
|
|
500
503
|
const results = [];
|
|
501
504
|
for (const c of fw.controls || []) {
|
|
502
505
|
const obs = [];
|
|
@@ -599,6 +602,12 @@ export function evaluateFramework(scanRoot, fw, scan) {
|
|
|
599
602
|
allCleared = false;
|
|
600
603
|
contributingFindings.push(...open);
|
|
601
604
|
obs.push(`${open.length} open ${fam} finding(s) at ${minSeverity}+.`);
|
|
605
|
+
} else if (langIncomplete.incomplete && LANGUAGE_COMPLIANCE_FAMILIES.has(fam)) {
|
|
606
|
+
// Empty bucket, but Haskell/Nix analysis in this scan did not complete (a parser failed, a required analyzer
|
|
607
|
+
// was off, an effective configuration is partial): an empty bucket is then not evidence, and an unknown
|
|
608
|
+
// option or an unresolved taint result must never read as satisfied.
|
|
609
|
+
obs.push(`⚠ ${fam}: Haskell/Nix analysis was incomplete (${langIncomplete.reasons[0]}${langIncomplete.reasons.length > 1 ? `; +${langIncomplete.reasons.length - 1} more` : ''}), so an empty finding bucket is not evidence (not assessed).`);
|
|
610
|
+
hasUnverifiableMapping = true;
|
|
602
611
|
} else if (!assessable) {
|
|
603
612
|
// Empty bucket, but the scan examined nothing — see the guard above.
|
|
604
613
|
// Not evidence, and deliberately not a failure either.
|
|
@@ -855,4 +864,36 @@ export function persistWalkthrough(scanRoot, fw, body) {
|
|
|
855
864
|
return fp;
|
|
856
865
|
}
|
|
857
866
|
|
|
867
|
+
/**
|
|
868
|
+
* A signable evidence document for one framework evaluation (the same shape `signComplianceEvidence` covers), so a
|
|
869
|
+
* walkthrough can be handed to an auditor with an integrity signature. Statuses, observations and finding references
|
|
870
|
+
* are copied from the evaluation; nothing is added. The language-analysis gaps that capped any control travel in
|
|
871
|
+
* `provenance`, so a reader of the signed document sees why a control is "partial".
|
|
872
|
+
*/
|
|
873
|
+
export function walkthroughToEvidenceJsonLd(fw, evaluation, { scan = null, engineVersion = null, generatedAt = null } = {}) {
|
|
874
|
+
const controls = (evaluation || []).map((e) => ({
|
|
875
|
+
'@type': 'Control', id: e.control && e.control.id, title: e.control && e.control.summary, status: e.status,
|
|
876
|
+
observations: e.observations || [], controlRefs: e.controlRefs || [],
|
|
877
|
+
codeTestable: (e.control && e.control.codeTestable) || null,
|
|
878
|
+
}));
|
|
879
|
+
const count = (st) => controls.filter((c) => c.status === st).length;
|
|
880
|
+
const gaps = languageAnalysisGaps(scan);
|
|
881
|
+
const digest = crypto.createHash('sha256').update(JSON.stringify(controls)).digest('hex');
|
|
882
|
+
return {
|
|
883
|
+
'@context': { '@vocab': 'https://agentic-security.io/compliance/v1/', schema: 'https://schema.org/' },
|
|
884
|
+
'@type': 'ComplianceEvidence',
|
|
885
|
+
schemaVersion: 1,
|
|
886
|
+
statusSemantics: 'present = no open findings on every mapped family and nothing capped it; partial = evidence with a gap or an unverifiable mapping; absent = nothing checked out; manual = no automated mapping',
|
|
887
|
+
policySource: `bundled:${fw.id}`,
|
|
888
|
+
framework: fw.id,
|
|
889
|
+
version: fw.controlsDigest || null,
|
|
890
|
+
generatedAt: generatedAt || new Date().toISOString(),
|
|
891
|
+
disclaimer: EVIDENCE_GRADE_DISCLAIMER_SHORT,
|
|
892
|
+
provenance: { engineVersion, languageAnalysis: { present: gaps.present, incomplete: gaps.incomplete, reasons: gaps.reasons } },
|
|
893
|
+
evidenceDigest: digest,
|
|
894
|
+
summary: { total: controls.length, present: count('present'), partial: count('partial'), absent: count('absent'), manual: count('manual') },
|
|
895
|
+
controls,
|
|
896
|
+
};
|
|
897
|
+
}
|
|
898
|
+
|
|
858
899
|
export const _internals = { _readJson, _resolveOpenFindingMinSeverity, SEVERITY_POLICY_FILE };
|
|
@@ -38,8 +38,8 @@ function sinkKey(f) {
|
|
|
38
38
|
// finding reaching this point has a distinct line — folding the line into
|
|
39
39
|
// the key here can only split buckets more finely, never wrongly merge one.
|
|
40
40
|
const sinkExpr = (parser === 'IR-TAINT'
|
|
41
|
-
? `L${f.sink?.line || f.line || 0}:${f.sink?.label || ''}`
|
|
42
|
-
:
|
|
41
|
+
? `L${f.sink?.line || f.line || 0}:${f.sink?.label || ''}${f.siteKey ? `:${f.siteKey}` : ''}` // Haskell: distinct sinks on one line stay distinct
|
|
42
|
+
: `${f.sink?.label || f.sink?.snippet || f.snippet || ''}${f.siteKey ? `|${f.siteKey}` : ''}`)
|
|
43
43
|
.replace(/['"`][^'"`]*['"`]/g, '_S_')
|
|
44
44
|
.replace(/\s+/g, ' ')
|
|
45
45
|
.trim()
|
|
@@ -197,6 +197,12 @@ export function buildEvidenceBundle(finding, { engineVersion, rulesetVersion, bu
|
|
|
197
197
|
cwe: finding.cwe ?? null,
|
|
198
198
|
family: finding.family ?? null,
|
|
199
199
|
parser: finding.parser ?? null,
|
|
200
|
+
// Haskell/Nix findings carry where the code is and what kind of evidence decided them. Added only when present, so
|
|
201
|
+
// the canonical bytes (and signatures) of every existing bundle are unchanged.
|
|
202
|
+
...(finding.language ? { language: finding.language } : {}),
|
|
203
|
+
...(finding.evidenceKind ? { evidenceKind: finding.evidenceKind } : {}),
|
|
204
|
+
...(finding.originalLocation ? { originalLocation: finding.originalLocation } : {}),
|
|
205
|
+
...(finding.generatedLocation ? { generatedLocation: finding.generatedLocation } : {}),
|
|
200
206
|
},
|
|
201
207
|
evidence: {
|
|
202
208
|
// The honest chain, in the vocabulary the engine already uses.
|
|
@@ -206,7 +212,7 @@ export function buildEvidenceBundle(finding, { engineVersion, rulesetVersion, bu
|
|
|
206
212
|
exploitability: finding.exploitability ?? null,
|
|
207
213
|
unreachable: finding.unreachable ?? null,
|
|
208
214
|
// Present only for the layers that produce them; absent means absent.
|
|
209
|
-
taintPath: finding.pathSteps ?? finding.exampleFlows ?? null,
|
|
215
|
+
taintPath: finding.pathSteps ?? finding.exampleFlows ?? (Array.isArray(finding.chain) && finding.language ? finding.chain : null),
|
|
210
216
|
discovery: finding.discovery ?? null,
|
|
211
217
|
},
|
|
212
218
|
engine: {
|