@clear-capabilities/agentic-security-scanner 0.153.2 → 0.154.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 +34 -0
- package/bin/agentic-security.js +123 -19
- package/dist/1444.index.js +64 -19
- package/dist/1905.index.js +46 -3
- 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 +1164 -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 +17 -2
- 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 +146 -0
- package/src/language/secrets.js +323 -0
- package/src/language/state-artifacts.js +78 -0
- package/src/language/support-registry.js +224 -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
|
@@ -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: {
|
|
@@ -80,6 +80,21 @@ export async function proveFinding(finding, { timeoutMs = DEFAULT_PROOF_TIMEOUT_
|
|
|
80
80
|
if (!poc?.code) {
|
|
81
81
|
return attachProofTier(finding, _evidence({ tier: proofTierOf(finding), reason: 'no proof-of-concept attached' }));
|
|
82
82
|
}
|
|
83
|
+
// X-009: language witnesses (generated Nix shell scripts; Haskell programs when a toolchain exists). The proof is the
|
|
84
|
+
// same marker file; the SCOPE is recorded so a configuration/generated-script proof is never read as runtime
|
|
85
|
+
// exploitation of a deployed system.
|
|
86
|
+
if (poc.lang === 'nix-shell' || poc.lang === 'haskell') {
|
|
87
|
+
const W = await import('../language/witness.js');
|
|
88
|
+
const r = poc.lang === 'haskell' ? W.runHaskellWitness(poc.code, { timeoutMs, force }) : W.runNixScriptWitness(poc.witness, { timeoutMs, force });
|
|
89
|
+
const scope = poc.lang === 'nix-shell'
|
|
90
|
+
? { proofKind: 'generated-script-execution', doesNotProve: 'that a deployed host evaluates this configuration or that the interpolated option is attacker-controlled there' }
|
|
91
|
+
: { proofKind: 'source-witness-execution', doesNotProve: 'that the deployed application receives attacker input on this path' };
|
|
92
|
+
return attachProofTier(finding, _evidence({
|
|
93
|
+
tier: r.status === 'reproduced' ? 'execution-proven' : r.status === 'not-reproduced' ? 'proof-failed' : proofTierOf(finding),
|
|
94
|
+
backend: r.backend || detectBackend(), ran: !!r.ran, observed: r.observed || null, reason: r.reason || null, exitCode: r.exitCode ?? null, timedOut: !!r.timedOut,
|
|
95
|
+
witnessStatus: r.status, ...scope,
|
|
96
|
+
}));
|
|
97
|
+
}
|
|
83
98
|
if (poc.lang !== 'js') {
|
|
84
99
|
return attachProofTier(finding, _evidence({ tier: proofTierOf(finding), reason: `unsupported poc language: ${poc.lang}` }));
|
|
85
100
|
}
|
|
@@ -90,6 +90,13 @@ export function classifyFinding(finding, fileContents) {
|
|
|
90
90
|
if (finding.sanitizerMismatch) {
|
|
91
91
|
return { verdict: 'survived', reasons: ['wrong-context sanitizer does not neutralize this sink'] };
|
|
92
92
|
}
|
|
93
|
+
// Haskell and Nix findings are judged on the IR / effective configuration, never on adjacent text: the taint
|
|
94
|
+
// engine already refuses a flow that a DOMINATING, context-matched guard covers (language/haskell-guards.js), and
|
|
95
|
+
// a NixOS value is decided by option precedence. A sanitizer-looking word near the sink (a comment, a helper that
|
|
96
|
+
// is not on the path, a control for a different family) is not control evidence, so text proximity never blocks.
|
|
97
|
+
if (/\.(?:l?hs|nix)$/i.test(String(finding.file || ''))) {
|
|
98
|
+
return { verdict: 'survived', reasons: ['judged on the IR/effective configuration: adjacent text is not control evidence'] };
|
|
99
|
+
}
|
|
93
100
|
const window = _pathWindow(finding, fileContents);
|
|
94
101
|
if (!window || !window.trim()) {
|
|
95
102
|
return { verdict: 'unproven', reasons: ['no source context available to attempt falsification'] };
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
//
|
|
11
11
|
// One definition, no cycle, both callers import from here.
|
|
12
12
|
|
|
13
|
+
import { LANGUAGE_FAMILY_ALIASES } from '../language/compliance-map.js';
|
|
14
|
+
|
|
13
15
|
export const COMPLIANCE_FAMILY_ALIAS = {
|
|
14
16
|
// ASVS spells it `sqli`; every detector emits `sql-injection` (or a
|
|
15
17
|
// language-prefixed variant, which the suffix rule below does NOT cover
|
|
@@ -45,3 +47,8 @@ export function resolveFamilyKeys(fam, availableKeys) {
|
|
|
45
47
|
return out;
|
|
46
48
|
}
|
|
47
49
|
|
|
50
|
+
// Haskell/Nix producers emit finer families than the frameworks name; those are evidence for the framework family
|
|
51
|
+
// (language/compliance-map.js). Merged without disturbing any existing alias.
|
|
52
|
+
for (const [fam, extra] of Object.entries(LANGUAGE_FAMILY_ALIASES)) {
|
|
53
|
+
COMPLIANCE_FAMILY_ALIAS[fam] = [...new Set([...(COMPLIANCE_FAMILY_ALIAS[fam] || []), ...extra])];
|
|
54
|
+
}
|
|
@@ -356,6 +356,46 @@ export async function applyFix({ scanRoot, file, originalContent, newContent, fi
|
|
|
356
356
|
});
|
|
357
357
|
}
|
|
358
358
|
|
|
359
|
+
/**
|
|
360
|
+
* Records a fix that a language lifecycle (Haskell, Nix) has ALREADY written, with its own backup, so one ledger
|
|
361
|
+
* serves every ecosystem: `agentic-security undo` reverts it, the attempt budget counts it, and its tier (FULL,
|
|
362
|
+
* MITIGATION, WORKAROUND) and the gates it passed are kept on the entry beside the timestamp. No second write happens
|
|
363
|
+
* here; `backup.original` is the file the lifecycle saved before it wrote.
|
|
364
|
+
*/
|
|
365
|
+
export async function recordExternalFix({ scanRoot, file, originalContent, newContent, findingId, ruleId, vuln, stableId, fixLabel = null, verification = null, backup = null, source = 'language-lifecycle', findingProvenance = null }) {
|
|
366
|
+
return _withLogLock(scanRoot, async () => {
|
|
367
|
+
if (!ensure(scanRoot)) return null;
|
|
368
|
+
const log = readLog(scanRoot);
|
|
369
|
+
const priorAttempts = _countPriorAttempts(log, stableId || null, findingId);
|
|
370
|
+
const bakAbs = backup && backup.dir ? path.join(backup.dir, 'original') : null;
|
|
371
|
+
const entry = {
|
|
372
|
+
id: `fix-${Date.now().toString(36)}-${sha(file + findingId).slice(0, 6)}`,
|
|
373
|
+
findingId, stableId: stableId || null, ruleId: ruleId || null, vuln: vuln || null, file, fileExisted: true,
|
|
374
|
+
backupPath: bakAbs ? path.relative(scanRoot, bakAbs) : null,
|
|
375
|
+
originalSha: sha(originalContent), newSha: sha(newContent),
|
|
376
|
+
appliedAt: new Date().toISOString(), status: 'applied', reverted: false, attemptOrdinal: priorAttempts + 1,
|
|
377
|
+
provenanceAtFix: _snapshotProvenanceAtFix(findingProvenance, new Date().toISOString()),
|
|
378
|
+
fixLabel, verification, source, languageBackupId: backup ? backup.id : null,
|
|
379
|
+
};
|
|
380
|
+
log.push(entry);
|
|
381
|
+
await _writeLogAndSync(scanRoot, log);
|
|
382
|
+
return entry;
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Marks the history entry written for a language-lifecycle backup as reverted (sync: the lifecycle's undo is sync). */
|
|
387
|
+
export function markRevertedByBackup(scanRoot, backupId) {
|
|
388
|
+
try {
|
|
389
|
+
const fp = logPath(scanRoot);
|
|
390
|
+
if (!fs.existsSync(fp)) return false;
|
|
391
|
+
const log = JSON.parse(fs.readFileSync(fp, 'utf8'));
|
|
392
|
+
let hit = false;
|
|
393
|
+
for (const e of log) if (e && e.languageBackupId === backupId && !e.reverted) { e.reverted = true; e.revertedAt = new Date().toISOString(); hit = true; }
|
|
394
|
+
if (hit) { const tmp = `${fp}.tmp-${process.pid}`; fs.writeFileSync(tmp, JSON.stringify(log, null, 2)); fs.renameSync(tmp, fp); }
|
|
395
|
+
return hit;
|
|
396
|
+
} catch { return false; }
|
|
397
|
+
}
|
|
398
|
+
|
|
359
399
|
async function _writeAndSync(fp, content) {
|
|
360
400
|
await fsp.mkdir(path.dirname(fp), { recursive: true });
|
|
361
401
|
const handle = await fsp.open(fp, 'w');
|
|
@@ -35,8 +35,17 @@ export async function verifyPatch({
|
|
|
35
35
|
depFileContents = {},
|
|
36
36
|
} = {}) {
|
|
37
37
|
if (!files || typeof files !== 'object') return { ok: false, reason: 'no-files-provided' };
|
|
38
|
-
|
|
38
|
+
let fileContents = { ...files };
|
|
39
39
|
let scan;
|
|
40
|
+
// Haskell/Nix findings come from the deep (IR) layer and depend on imported modules: verifying a patch without
|
|
41
|
+
// either would never see the original finding at all, so a patch that fixes nothing would read "verified". The
|
|
42
|
+
// patched files are scanned deep, together with their import closure and manifests read from disk.
|
|
43
|
+
let deep;
|
|
44
|
+
if (Object.keys(fileContents).some((f) => /\.(?:l?hs|hs-boot|hsc|nix)$/i.test(f))) {
|
|
45
|
+
const { withLanguageContext } = await import('../language/context.js');
|
|
46
|
+
const lc = withLanguageContext(scanRoot, fileContents, depFileContents);
|
|
47
|
+
fileContents = lc.fileContents; depFileContents = lc.depFileContents; deep = true;
|
|
48
|
+
}
|
|
40
49
|
try {
|
|
41
50
|
// `provenance:false` is REQUIRED here, not an optimisation. This scan is
|
|
42
51
|
// deliberately scoped to just the patched file(s), so its finding set is a
|
|
@@ -46,7 +55,7 @@ export async function verifyPatch({
|
|
|
46
55
|
// would mass-mark the rest of the project as remediated, then
|
|
47
56
|
// `reintroduced` on the next real scan. The patched content is also not
|
|
48
57
|
// committed, so there is no history to resolve provenance against anyway.
|
|
49
|
-
scan = await runFullScan({ fileContents, depFileContents, scanRoot, provenance: false }, () => {});
|
|
58
|
+
scan = await runFullScan({ fileContents, depFileContents, scanRoot, provenance: false, ...(deep ? { deep: true } : {}) }, () => {});
|
|
50
59
|
} catch (e) {
|
|
51
60
|
return { ok: false, reason: 'rescan-failed', error: e.message };
|
|
52
61
|
}
|
|
@@ -56,8 +65,11 @@ export async function verifyPatch({
|
|
|
56
65
|
if (stillHasOriginal) {
|
|
57
66
|
return { ok: false, reason: 'original-finding-still-present', stableId: originalFindingStableId };
|
|
58
67
|
}
|
|
68
|
+
// With language context in play, files that were only read for context carry findings that predate the patch;
|
|
69
|
+
// only findings in the PATCHED files can be introduced by it.
|
|
70
|
+
const patchedSet = new Set(Object.keys(files));
|
|
59
71
|
const introducedHighOrAbove = findings.filter(f =>
|
|
60
|
-
(SEVERITY_RANK[f.severity] ?? 9) <= SEVERITY_RANK.medium);
|
|
72
|
+
(SEVERITY_RANK[f.severity] ?? 9) <= SEVERITY_RANK.medium && (!deep || patchedSet.has(f.file)));
|
|
61
73
|
// Don't count findings on lines outside the patched files — but our
|
|
62
74
|
// fileContents map IS the patched files, so every finding is in-scope.
|
|
63
75
|
return {
|
|
@@ -233,6 +233,30 @@ export function getBlobAtCommit(scanRoot, sha, file) {
|
|
|
233
233
|
return r.ok ? r.stdout : null;
|
|
234
234
|
}
|
|
235
235
|
|
|
236
|
+
/** Files in the tree at `sha` (relative to scanRoot) whose path matches `re`, capped. Empty on any failure. */
|
|
237
|
+
export function listFilesAtCommit(scanRoot, sha, re, { max = 400 } = {}) {
|
|
238
|
+
if (!_isSha(sha) || !(re instanceof RegExp)) return [];
|
|
239
|
+
const r = _run(scanRoot, ['ls-tree', '-r', '--name-only', sha, '--', '.']);
|
|
240
|
+
if (!r.ok) return [];
|
|
241
|
+
return r.stdout.split('\n').map((x) => x.trim()).filter((x) => x && re.test(x)).slice(0, max);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Commits, oldest first, that touched ANY of `files`. Used when a finding is decided by several files (an import
|
|
246
|
+
* chain, a module graph) so the commit that changed any of them is a candidate origin. No rename following.
|
|
247
|
+
*/
|
|
248
|
+
export function candidateCommitsForFiles(scanRoot, files, { since } = {}) {
|
|
249
|
+
const rels = (files || []).map((f) => _relPath(scanRoot, f)).filter(Boolean);
|
|
250
|
+
if (!rels.length) return [];
|
|
251
|
+
if (since && !_isSafeRevision(since)) return [];
|
|
252
|
+
const args = ['log', '--format=%H', '--reverse'];
|
|
253
|
+
if (since) args.push(`${since}..HEAD`);
|
|
254
|
+
args.push('--', ...rels);
|
|
255
|
+
const r = _run(scanRoot, args);
|
|
256
|
+
if (!r.ok) return [];
|
|
257
|
+
return [...new Set(r.stdout.split('\n').map((x) => x.trim()).filter(Boolean))];
|
|
258
|
+
}
|
|
259
|
+
|
|
236
260
|
export function candidateCommitsForLine(scanRoot, file, line, { since } = {}) {
|
|
237
261
|
const rel = _relPath(scanRoot, file);
|
|
238
262
|
if (!rel) return [];
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
// that disambiguates them; see the branch below for exactly how each is
|
|
24
24
|
// handled.
|
|
25
25
|
|
|
26
|
-
import { candidateCommitsForLine, commitMeta, getBlobAtCommit, isAncestor } from './git-evidence.js';
|
|
26
|
+
import { candidateCommitsForLine, candidateCommitsForFiles, commitMeta, getBlobAtCommit, isAncestor } from './git-evidence.js';
|
|
27
27
|
import { replayAt } from './predicate-replay.js';
|
|
28
28
|
import { PROVENANCE_METHOD } from './schema.js';
|
|
29
29
|
import { checkAbsentInSomeParent, detectRevert, detectCherryPick } from './dag-walk.js';
|
|
@@ -38,6 +38,11 @@ function relevantFiles(finding) {
|
|
|
38
38
|
if (Array.isArray(finding.pathSteps)) {
|
|
39
39
|
for (const step of finding.pathSteps) if (step.file) files.add(step.file);
|
|
40
40
|
}
|
|
41
|
+
// Haskell/Nix findings are decided by more than the line they point at: a taint chain crosses modules, and a
|
|
42
|
+
// NixOS option is decided by imported modules. Replaying only the anchor file would attribute the finding to the
|
|
43
|
+
// commit that last touched that one file, so the files that decided it are replayed together.
|
|
44
|
+
if (Array.isArray(finding.chain)) for (const step of finding.chain) if (step && typeof step.file === 'string' && step.file) files.add(step.file);
|
|
45
|
+
if (Array.isArray(finding.controlFiles)) for (const f of finding.controlFiles) if (typeof f === 'string' && f) files.add(f);
|
|
41
46
|
return [...files];
|
|
42
47
|
}
|
|
43
48
|
|
|
@@ -173,7 +178,14 @@ export async function resolveOrigin(scanRoot, finding, { since, deadlineAt, repo
|
|
|
173
178
|
return { status: 'not_available', reason: 'missing-file-line-or-stableId', commitsConsidered: 0 };
|
|
174
179
|
}
|
|
175
180
|
|
|
176
|
-
|
|
181
|
+
let candidates = candidateCommitsForLine(scanRoot, file, line, { since });
|
|
182
|
+
// Haskell/Nix: more than the anchor line decides a finding (an import chain, a module graph), so the commits that
|
|
183
|
+
// touched ANY of the deciding files are candidates too, in chronological order.
|
|
184
|
+
const decidingFiles = relevantFiles(finding);
|
|
185
|
+
if (/\.nix$/i.test(file) || (decidingFiles.length > 1 && /\.l?hs$/i.test(file))) {
|
|
186
|
+
const wide = candidateCommitsForFiles(scanRoot, decidingFiles, { since });
|
|
187
|
+
if (wide.length) candidates = [...new Set([...wide, ...candidates])];
|
|
188
|
+
}
|
|
177
189
|
if (candidates.length === 0) {
|
|
178
190
|
return { status: 'not_available', reason: 'no-candidate-commits', commitsConsidered: 0 };
|
|
179
191
|
}
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import { runFullScan, _snapshotSuppressionLog, _restoreSuppressionLog } from '../../engine.js';
|
|
13
13
|
import { computeStableId } from '../stable-id.js';
|
|
14
|
-
import { getBlobAtCommit } from './git-evidence.js';
|
|
14
|
+
import { getBlobAtCommit, listFilesAtCommit } from './git-evidence.js';
|
|
15
15
|
|
|
16
16
|
// Task 11 concurrency fix: `coordinator.js` resolves several findings' origins
|
|
17
17
|
// CONCURRENTLY (its own comment: "the scheduler runs these four at a time"),
|
|
@@ -59,6 +59,12 @@ export function _runExclusive(fn) {
|
|
|
59
59
|
|
|
60
60
|
export async function replayAt(scanRoot, sha, files, targetStableId) {
|
|
61
61
|
const fileContents = {};
|
|
62
|
+
// A NixOS option is decided by every module the configuration imports, including ones deleted later, so a Nix
|
|
63
|
+
// finding is replayed against ALL Nix sources and the lock file as they existed at that commit.
|
|
64
|
+
if (files.some((f) => /\.nix$/i.test(f))) {
|
|
65
|
+
const all = listFilesAtCommit(scanRoot, sha, /(?:\.nix|(?:^|\/)flake\.lock)$/i);
|
|
66
|
+
files = [...new Set([...files, ...all])];
|
|
67
|
+
}
|
|
62
68
|
for (const f of files) {
|
|
63
69
|
const content = getBlobAtCommit(scanRoot, sha, f);
|
|
64
70
|
if (content != null) fileContents[f] = content;
|
|
@@ -100,7 +106,10 @@ export async function replayAt(scanRoot, sha, files, targetStableId) {
|
|
|
100
106
|
// identical computeStableId output with and without annotators, over
|
|
101
107
|
// a real scan) before this was wired in — see the FR-PROV-029 commit
|
|
102
108
|
// message for methodology.
|
|
103
|
-
|
|
109
|
+
// Haskell/Nix findings come from the IR-taint layer, which only runs in deep mode: a replay that skips it
|
|
110
|
+
// can never see the finding at ANY commit, so origin resolution would end "never confirmed" for every one.
|
|
111
|
+
const _langDeep = Object.keys(fileContents).some((f) => /\.(?:l?hs|nix)$/i.test(f)) ? { deep: true } : {};
|
|
112
|
+
return await runFullScan({ fileContents, scanRoot, provenance: false, skipAnnotators: true, ..._langDeep }, () => {});
|
|
104
113
|
} finally {
|
|
105
114
|
_restoreSuppressionLog(_suppSnapshot);
|
|
106
115
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as crypto from 'node:crypto';
|
|
2
2
|
import { candidateCommitsForFile, getFirstParent, getBlobAtCommit, commitMeta } from './git-evidence.js';
|
|
3
3
|
import { PROVENANCE_METHOD } from './schema.js';
|
|
4
|
+
import { compareVersions } from '../../language/haskell-manifests.js';
|
|
4
5
|
|
|
5
6
|
function parseSemver(v) {
|
|
6
7
|
const m = String(v || '').replace(/^[^\d]*/, '').match(/^(\d+)\.(\d+)\.(\d+)/);
|
|
@@ -13,7 +14,17 @@ function cmpSemver(a, b) {
|
|
|
13
14
|
return 0;
|
|
14
15
|
}
|
|
15
16
|
|
|
16
|
-
|
|
17
|
+
// Hackage versions follow the PVP: any number of components, compared numerically component by component. The
|
|
18
|
+
// three-component semver comparison below would call 2.1.2.1 and 2.1.2.9 equal.
|
|
19
|
+
function pvpInRange(version, range) {
|
|
20
|
+
if (!version) return false;
|
|
21
|
+
if (range.introduced && compareVersions(version, range.introduced) < 0) return false;
|
|
22
|
+
if (range.fixed && compareVersions(version, range.fixed) >= 0) return false;
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function versionInRange(version, range, ecosystem = null) {
|
|
27
|
+
if (ecosystem && String(ecosystem).toLowerCase() === 'hackage') return pvpInRange(version, range);
|
|
17
28
|
const v = parseSemver(version);
|
|
18
29
|
if (!v) return false;
|
|
19
30
|
if (range.introduced) {
|
|
@@ -42,6 +53,12 @@ function extractDeclaredVersion(blobText, depName, filePath) {
|
|
|
42
53
|
return null;
|
|
43
54
|
}
|
|
44
55
|
}
|
|
56
|
+
// Hackage: the version is exact only where a freeze, a Stack lock or an extra-deps pin states it.
|
|
57
|
+
const esc = String(depName).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
58
|
+
if (base === 'cabal.project.freeze') { const m = new RegExp(`any\\.${esc}\\s*==\\s*([0-9][0-9.]*)`).exec(blobText); return m ? m[1] : null; }
|
|
59
|
+
if (base === 'stack.yaml.lock') { const m = new RegExp(`hackage:\\s*${esc}-([0-9][0-9.]*)(?:@|\\s|$)`).exec(blobText); return m ? m[1] : null; }
|
|
60
|
+
if (base === 'stack.yaml') { const m = new RegExp(`^\\s*-\\s*${esc}-([0-9][0-9.]*)\\s*$`, 'm').exec(blobText); return m ? m[1] : null; }
|
|
61
|
+
if (/\.cabal$/i.test(base)) { const m = new RegExp(`(?:^|[\\s,:])${esc}\\s*==\\s*([0-9][0-9.]*)(?![\\w.*])`, 'm').exec(blobText); return m ? m[1] : null; }
|
|
45
62
|
if (/^requirements(?:[._-][\w.-]+)?\.txt$/i.test(base)) {
|
|
46
63
|
for (const line of blobText.split('\n')) {
|
|
47
64
|
const m = line.trim().match(/^([A-Za-z0-9_.-]+)\s*[=~<>!]+\s*([^\s;#,]*)/);
|
|
@@ -98,7 +115,7 @@ export async function resolveDirectSCAOrigin(scanRoot, scaEntry, { since, deadli
|
|
|
98
115
|
const blob = getBlobAtCommit(scanRoot, sha, file);
|
|
99
116
|
if (blob == null) continue;
|
|
100
117
|
const declaredVersion = extractDeclaredVersion(blob, scaEntry.name, file);
|
|
101
|
-
if (!declaredVersion || !versionInRange(declaredVersion, range)) continue;
|
|
118
|
+
if (!declaredVersion || !versionInRange(declaredVersion, range, scaEntry.ecosystem)) continue;
|
|
102
119
|
|
|
103
120
|
const parent = getFirstParent(scanRoot, sha);
|
|
104
121
|
if (!parent) {
|
|
@@ -111,7 +128,7 @@ export async function resolveDirectSCAOrigin(scanRoot, scaEntry, { since, deadli
|
|
|
111
128
|
|
|
112
129
|
const parentBlob = getBlobAtCommit(scanRoot, parent, file);
|
|
113
130
|
const parentVersion = parentBlob ? extractDeclaredVersion(parentBlob, scaEntry.name, file) : null;
|
|
114
|
-
const parentOutOfRange = !parentVersion || !versionInRange(parentVersion, range);
|
|
131
|
+
const parentOutOfRange = !parentVersion || !versionInRange(parentVersion, range, scaEntry.ecosystem);
|
|
115
132
|
if (!parentOutOfRange) {
|
|
116
133
|
// Parent was already in the vulnerable range too. If the declared value
|
|
117
134
|
// actually changed here, this is the ambiguous "still-vulnerable bump"
|