@clear-capabilities/agentic-security-scanner 0.153.1 → 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 +67 -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 +18 -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 +140 -19
- 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/code-injection-multilang.js +15 -0
- package/src/sast/go-extended.js +11 -0
- package/src/sast/pipeline.js +34 -1
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// Nix configuration lineage (X-004 / X-005). Contributes nodes, data elements, edges, flows and evidence to an
|
|
2
|
+
// EXISTING DataFlowGraph v1; it never forks the schema. The inputs are the NIX-006 secret-placement findings
|
|
3
|
+
// (which already include secrets interpolated into generated scripts), so a credential-bearing option is followed
|
|
4
|
+
// to where the Nix evaluation puts it: the world-readable store, a derivation environment, a log, or the source
|
|
5
|
+
// file itself.
|
|
6
|
+
//
|
|
7
|
+
// Scope and honesty:
|
|
8
|
+
// * Every contribution is STATIC CONFIGURATION evidence. A node carries `analysis: {language: 'nix',
|
|
9
|
+
// kind: 'configuration', scope: 'static-configuration', runtime: false}`: a declared service or a store
|
|
10
|
+
// path is not proof that a value is exposed at runtime, and nothing here claims it is.
|
|
11
|
+
// * A runtime reference (a `…File` option, EnvironmentFile, sops/agenix path) is not a flow; the secret analyzer
|
|
12
|
+
// already treats it as safe and so does this view.
|
|
13
|
+
// * The original location is the Nix source; a generated-script location is kept as `generatedLocation` when the
|
|
14
|
+
// value lands in a generated shell script. Neither is invented.
|
|
15
|
+
// * No application-level link is inferred: a Nix store file does not become an application read by name.
|
|
16
|
+
|
|
17
|
+
import * as ids from './ids.js';
|
|
18
|
+
import { classifyDataElementName } from './classification.js';
|
|
19
|
+
import { analyzeNixSecrets } from '../language/nix-secrets.js';
|
|
20
|
+
import { analyzeNixPersonalData, destinationOf } from '../language/nix-privacy.js';
|
|
21
|
+
|
|
22
|
+
export const NIX_VIEW_VERSION = 'nix-lineage/1';
|
|
23
|
+
|
|
24
|
+
const ANALYSIS = Object.freeze({ language: 'nix', kind: 'configuration', scope: 'static-configuration', runtime: false });
|
|
25
|
+
|
|
26
|
+
// exposure -> sink decision. Each is a registry-shaped decision (kind + subtype), never a call site.
|
|
27
|
+
const SINKS = {
|
|
28
|
+
store: { kind: 'store', subtype: 'file', label: 'Nix store (world-readable)', stage: 'storage' },
|
|
29
|
+
build: { kind: 'store', subtype: 'file', label: 'Derivation environment (recorded in the .drv)', stage: 'storage' },
|
|
30
|
+
log: { kind: 'log', subtype: 'log', label: 'Evaluation or script log output', stage: 'storage' },
|
|
31
|
+
'plaintext-source': { kind: 'store', subtype: 'file', label: 'Configuration source file', stage: 'storage' },
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const lastSegment = (detail) => String(detail || '').replace(/^option\s+/, '').replace(/^config\./, '').split('.').filter(Boolean).pop() || 'secret';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Adds Nix configuration lineage to `graph` in place. Returns `{added, nixFiles, gaps}`.
|
|
38
|
+
* @param {object} graph a DataFlowGraph v1 (mutated)
|
|
39
|
+
* @param {Record<string,string>} files every file handed to the scan
|
|
40
|
+
* @param {{repository?: string}} opts
|
|
41
|
+
*/
|
|
42
|
+
export function contributeNixConfigLineage(graph, files, opts = {}) {
|
|
43
|
+
const nixFiles = Object.keys(files || {}).filter((f) => /\.nix$/i.test(f));
|
|
44
|
+
const result = { added: { nodes: 0, edges: 0, flows: 0, dataElements: 0 }, nixFiles: nixFiles.length, gaps: [] };
|
|
45
|
+
if (!nixFiles.length) return result;
|
|
46
|
+
const repository = opts.repository || '';
|
|
47
|
+
let analysis;
|
|
48
|
+
try { analysis = analyzeNixSecrets({ files }); } catch (e) { result.gaps.push(`nix secret analysis failed: ${String((e && e.message) || e)}`); return result; }
|
|
49
|
+
result.gaps.push(...(analysis.gaps || []).map((g) => (typeof g === 'string' ? g : (g && (g.reason || g.message)) || 'unresolved Nix construct')));
|
|
50
|
+
|
|
51
|
+
const nodes = new Map(graph.nodes.map((n) => [n.id, n]));
|
|
52
|
+
const els = new Map(graph.dataElements.map((d) => [d.id, d]));
|
|
53
|
+
const edges = new Map(graph.edges.map((e) => [e.id, e]));
|
|
54
|
+
const flows = new Map(graph.flows.map((f) => [f.id, f]));
|
|
55
|
+
const evidence = new Map(graph.evidence.map((e) => [e.id, e]));
|
|
56
|
+
|
|
57
|
+
const mint = (kind, subtype, label, stage, externality = 'internal') => {
|
|
58
|
+
const id = ids.nodeId(kind, [repository, `nix:${subtype}`, 'modeled', externality, '']);
|
|
59
|
+
let n = nodes.get(id);
|
|
60
|
+
if (!n) {
|
|
61
|
+
n = {
|
|
62
|
+
id, kind, subtype, label, aliases: [], location: null,
|
|
63
|
+
system: { application: repository, environment: null }, destination: null, storeDetail: null, queueDetail: null,
|
|
64
|
+
externality: { value: externality, evidenceRefs: [] }, lifecycleStages: [stage], governanceRefs: {},
|
|
65
|
+
dataElementIds: [], evidenceRefs: [], confidence: { score: 0.8, tier: 'high' }, coverageStatus: 'modeled', coverageReason: null,
|
|
66
|
+
analysis: ANALYSIS,
|
|
67
|
+
};
|
|
68
|
+
nodes.set(id, n); result.added.nodes++;
|
|
69
|
+
}
|
|
70
|
+
return n;
|
|
71
|
+
};
|
|
72
|
+
const source = mint('source', 'declared', 'Nix configuration value', 'collection');
|
|
73
|
+
|
|
74
|
+
for (const f of analysis.findings || []) {
|
|
75
|
+
const sink = SINKS[f.exposure];
|
|
76
|
+
if (!sink || !f.source) continue; // runtime references and unclassified exposures are not flows
|
|
77
|
+
const name = lastSegment(f.source.detail);
|
|
78
|
+
const deId = ids.dataElementId(name, [repository, f.source.file || f.file || '']);
|
|
79
|
+
let de = els.get(deId);
|
|
80
|
+
if (!de) {
|
|
81
|
+
const cls = classifyDataElementName(name).classes;
|
|
82
|
+
de = {
|
|
83
|
+
id: deId, name, aliases: [], declaredType: null, dataClasses: cls.length ? cls : ['CREDENTIALS'], aiContexts: [],
|
|
84
|
+
sourceLocations: [{ file: f.source.file || f.file, line: f.line, scope: 'nix', path: f.source.detail }],
|
|
85
|
+
dataSubjectCategory: null, classificationEvidence: [], manualOverride: false,
|
|
86
|
+
};
|
|
87
|
+
els.set(deId, de); result.added.dataElements++;
|
|
88
|
+
}
|
|
89
|
+
const snk = mint(sink.kind, sink.subtype, sink.label, sink.stage);
|
|
90
|
+
if (!source.dataElementIds.includes(deId)) source.dataElementIds.push(deId);
|
|
91
|
+
if (!snk.dataElementIds.includes(deId)) snk.dataElementIds.push(deId);
|
|
92
|
+
|
|
93
|
+
const edgeIdStr = ids.edgeId(source.id, snk.id, 'data_flow', [f.source.detail, f.exposure, deId, 'identity']);
|
|
94
|
+
if (!edges.has(edgeIdStr)) {
|
|
95
|
+
edges.set(edgeIdStr, {
|
|
96
|
+
id: edgeIdStr, from: source.id, to: snk.id, relationship: 'data_flow',
|
|
97
|
+
fieldMappings: [{ fromPath: f.source.detail, toPath: null, dataElementIds: [deId], mappingType: 'identity', transformationIds: [] }],
|
|
98
|
+
protocol: { name: 'nix-evaluation', destinationResolution: 'resolved_from_config' },
|
|
99
|
+
boundaryCrossings: [], provenance: 'code',
|
|
100
|
+
protection: { transit: { verdict: 'not_applicable', evidenceGrade: 'none' }, atRest: { verdict: f.exposure === 'log' ? 'not_assessed' : 'unprotected', evidenceGrade: 'config' }, handling: { verdict: 'not_assessed', evidenceGrade: 'none' } },
|
|
101
|
+
evidenceRefs: [], coverageStatus: 'modeled', analysis: ANALYSIS,
|
|
102
|
+
});
|
|
103
|
+
result.added.edges++;
|
|
104
|
+
}
|
|
105
|
+
const claim = `${f.rule}: ${f.source.detail} reaches ${sink.label}`;
|
|
106
|
+
const evId = ids.evidenceId(claim, `${f.file}:${f.line}`, [deId, f.exposure]);
|
|
107
|
+
if (!evidence.has(evId)) {
|
|
108
|
+
evidence.set(evId, {
|
|
109
|
+
id: evId, claim, evidenceType: 'configuration', location: { file: f.file, line: f.line, column: f.column ?? null, generatedLocation: f.generatedLocation || null },
|
|
110
|
+
producer: 'nix-secrets', confidenceTier: null, snippet: null, timestamp: null, commit: null,
|
|
111
|
+
limitations: ['static configuration evidence: the value is not proven to be present on a running host'], conflict: null,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
const fId = ids.flowId(source.id, snk.id, [deId], [f.exposure, f.rule]);
|
|
115
|
+
if (!flows.has(fId)) {
|
|
116
|
+
flows.set(fId, {
|
|
117
|
+
id: fId, dataElementIds: [deId], source: source.id, sink: snk.id, edgeIds: [edgeIdStr], transformationIds: [],
|
|
118
|
+
alternatePathCount: 0, policyVerdict: 'not_evaluated', protectionSummary: f.exposure === 'log' ? 'not_assessed' : 'unprotected',
|
|
119
|
+
evidenceRefs: [evId], confidence: { score: 0.8, tier: 'high' }, coverageStatus: 'modeled', findingRefs: [], governanceRefs: {},
|
|
120
|
+
limitations: ['static Nix configuration evidence', ...(f.generatedLocation ? ['the value lands in a generated script; both the original and generated locations are recorded'] : [])],
|
|
121
|
+
evidenceGrade: 'explicit', handling: 'raw', analysis: ANALYSIS,
|
|
122
|
+
});
|
|
123
|
+
result.added.flows++;
|
|
124
|
+
} else {
|
|
125
|
+
const ex = flows.get(fId);
|
|
126
|
+
if (!ex.evidenceRefs.includes(evId)) ex.evidenceRefs.push(evId);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Personal data (PII, PHI, PCI, financial) written into configuration: a field-to-store flow per unprotected reference.
|
|
131
|
+
let personal = { references: [], gaps: [] };
|
|
132
|
+
try { personal = analyzeNixPersonalData({ files }); } catch (e) { result.gaps.push(`nix personal-data analysis failed: ${String((e && e.message) || e)}`); }
|
|
133
|
+
result.gaps.push(...personal.gaps);
|
|
134
|
+
for (const ref of personal.references) {
|
|
135
|
+
if (ref.protected) continue; // hashed, measured or only compared: no flow of the value
|
|
136
|
+
const dest = destinationOf(ref.attr);
|
|
137
|
+
const sink = { kind: 'store', subtype: 'file', label: dest.label, stage: 'storage' };
|
|
138
|
+
const deId = ids.dataElementId(ref.field, [repository, ref.file]);
|
|
139
|
+
let de = els.get(deId);
|
|
140
|
+
if (!de) {
|
|
141
|
+
de = {
|
|
142
|
+
id: deId, name: ref.field, aliases: [], declaredType: null, dataClasses: ref.classes, aiContexts: [],
|
|
143
|
+
sourceLocations: [{ file: ref.file, line: ref.line, scope: 'nix', path: ref.option }],
|
|
144
|
+
dataSubjectCategory: null, classificationEvidence: [], manualOverride: false,
|
|
145
|
+
};
|
|
146
|
+
els.set(deId, de); result.added.dataElements++;
|
|
147
|
+
}
|
|
148
|
+
const snk = mint(sink.kind, sink.subtype, sink.label, sink.stage);
|
|
149
|
+
if (!source.dataElementIds.includes(deId)) source.dataElementIds.push(deId);
|
|
150
|
+
if (!snk.dataElementIds.includes(deId)) snk.dataElementIds.push(deId);
|
|
151
|
+
const edgeIdStr = ids.edgeId(source.id, snk.id, 'data_flow', [ref.option, 'personal-data', deId, 'identity']);
|
|
152
|
+
if (!edges.has(edgeIdStr)) {
|
|
153
|
+
edges.set(edgeIdStr, {
|
|
154
|
+
id: edgeIdStr, from: source.id, to: snk.id, relationship: 'data_flow',
|
|
155
|
+
fieldMappings: [{ fromPath: ref.option, toPath: ref.attr.join('.'), dataElementIds: [deId], mappingType: 'identity', transformationIds: [] }],
|
|
156
|
+
protocol: { name: 'nix-evaluation', destinationResolution: 'resolved_from_config' },
|
|
157
|
+
boundaryCrossings: [], provenance: 'code',
|
|
158
|
+
protection: { transit: { verdict: 'not_applicable', evidenceGrade: 'none' }, atRest: { verdict: 'unprotected', evidenceGrade: 'config' }, handling: { verdict: 'not_assessed', evidenceGrade: 'none' } },
|
|
159
|
+
evidenceRefs: [], coverageStatus: 'modeled', analysis: ANALYSIS,
|
|
160
|
+
});
|
|
161
|
+
result.added.edges++;
|
|
162
|
+
}
|
|
163
|
+
const claim = `nix-personal-data-in-store: ${ref.option} reaches ${sink.label}`;
|
|
164
|
+
const evId = ids.evidenceId(claim, `${ref.file}:${ref.line}`, [deId, 'personal-data']);
|
|
165
|
+
if (!evidence.has(evId)) {
|
|
166
|
+
evidence.set(evId, {
|
|
167
|
+
id: evId, claim, evidenceType: 'configuration', location: { file: ref.file, line: ref.line, column: null, generatedLocation: null },
|
|
168
|
+
producer: 'nix-privacy', confidenceTier: null, snippet: null, timestamp: null, commit: null,
|
|
169
|
+
limitations: ['static configuration evidence: the option may hold no real personal data, and the value is not proven present on a running host'], conflict: null,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
const fId = ids.flowId(source.id, snk.id, [deId], ['personal-data', ref.attr.join('.')]);
|
|
173
|
+
if (!flows.has(fId)) {
|
|
174
|
+
flows.set(fId, {
|
|
175
|
+
id: fId, dataElementIds: [deId], source: source.id, sink: snk.id, edgeIds: [edgeIdStr], transformationIds: [],
|
|
176
|
+
alternatePathCount: 0, policyVerdict: 'not_evaluated', protectionSummary: 'unprotected',
|
|
177
|
+
evidenceRefs: [evId], confidence: { score: 0.7, tier: 'medium' }, coverageStatus: 'modeled', findingRefs: [], governanceRefs: {},
|
|
178
|
+
limitations: ['static Nix configuration evidence', 'classified by the option name only'],
|
|
179
|
+
evidenceGrade: 'inferred', handling: 'raw', analysis: ANALYSIS,
|
|
180
|
+
});
|
|
181
|
+
result.added.flows++;
|
|
182
|
+
} else {
|
|
183
|
+
const ex = flows.get(fId);
|
|
184
|
+
if (!ex.evidenceRefs.includes(evId)) ex.evidenceRefs.push(evId);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const byId = (a, b) => (a.id < b.id ? -1 : 1);
|
|
189
|
+
graph.nodes = [...nodes.values()].sort(byId);
|
|
190
|
+
graph.dataElements = [...els.values()].sort(byId);
|
|
191
|
+
graph.edges = [...edges.values()].sort(byId);
|
|
192
|
+
graph.flows = [...flows.values()].sort(byId);
|
|
193
|
+
graph.evidence = [...evidence.values()].sort(byId);
|
|
194
|
+
if (result.added.flows) {
|
|
195
|
+
graph.limitations = [...graph.limitations, 'Nix configuration lineage is static: a store path or declared service is not proof of runtime exposure.'];
|
|
196
|
+
}
|
|
197
|
+
return result;
|
|
198
|
+
}
|
|
@@ -116,6 +116,10 @@ export const CWE_MAP = Object.freeze({
|
|
|
116
116
|
'CWE-22': Object.freeze({ category: 'file', status: 'modeled', why: 'path traversal sinks are filesystem reads/writes' }),
|
|
117
117
|
'CWE-73': Object.freeze({ category: 'file', status: 'modeled', why: 'arbitrary file write' }),
|
|
118
118
|
'CWE-918': Object.freeze({ category: 'external-api', status: 'modeled', why: 'SSRF sinks are outbound HTTP client calls' }),
|
|
119
|
+
// X-004 / haskell-models.js: untrusted text placed in a model request BODY. The call is an ordinary outbound HTTP
|
|
120
|
+
// body setter, so it is an external-api destination; whether the destination is a model provider is decided by
|
|
121
|
+
// the AI-evidence overlay (lineage/haskell-ai.js), never by this table.
|
|
122
|
+
'CWE-1427': Object.freeze({ category: 'external-api', status: 'modeled', why: 'a model request body is an outbound HTTP request body' }),
|
|
119
123
|
'CWE-601': Object.freeze({ category: 'http-response', status: 'modeled', why: 'a redirect is written as a response header' }),
|
|
120
124
|
'CWE-113': Object.freeze({ category: 'http-response', status: 'modeled', why: 'response splitting — the sink IS the response header writer' }),
|
|
121
125
|
// The sink side's ONE documented refinement (§5.2). `status: 'split'` is
|
|
@@ -136,6 +140,7 @@ export const CWE_MAP = Object.freeze({
|
|
|
136
140
|
// memory). FR-201's category list is an EGRESS taxonomy and models none
|
|
137
141
|
// of them — the single largest structural finding of D1 (§3/§7.1).
|
|
138
142
|
'CWE-78': Object.freeze({ category: null, status: 'unsupported', why: 'shell/process execution — no FR-201 category models process execution' }),
|
|
143
|
+
'CWE-88': Object.freeze({ category: null, status: 'unsupported', why: 'process-argument injection (Haskell argv sinks), no FR-201 category models process execution' }),
|
|
139
144
|
'CWE-95': Object.freeze({ category: null, status: 'unsupported', why: 'code evaluation (eval/Function/exec/compile) — destination is an interpreter' }),
|
|
140
145
|
'CWE-94': Object.freeze({ category: null, status: 'unsupported', why: 'code injection / template compilation — destination is an interpreter or template engine' }),
|
|
141
146
|
// PHP SARD catalog addition: include/require executes the included file
|
|
@@ -291,7 +296,11 @@ export function reclassifySink(entry, opts = {}) {
|
|
|
291
296
|
}
|
|
292
297
|
if (row.status === 'split') {
|
|
293
298
|
// The single documented refinement on the sink side (§5.2).
|
|
294
|
-
|
|
299
|
+
// A Haskell text-output sink (`putStrLn`) that is an XSS sink only when the string being assembled carries markup
|
|
300
|
+
// (`hs.htmlSkeleton`): the destination is standard output (a log), not a browser response.
|
|
301
|
+
row = entry.hs && entry.hs.htmlSkeleton
|
|
302
|
+
? { category: 'log', status: 'modeled', why: 'text written to standard output (the console is a log destination); an XSS sink to the taint engine only when the assembled string carries markup' }
|
|
303
|
+
: DOM_FRAMEWORKS.has(entry.framework)
|
|
295
304
|
? { category: 'client-storage', status: 'partial', why: "LOSSY: the destination is the rendered browser DOM; schema.js's `client-storage` is its encoding of FR-201's 'browser DOM or client storage' bullet, and under-names the DOM half" }
|
|
296
305
|
: { category: 'http-response', status: 'modeled', why: 'a server-side response writer (res.send / PrintWriter / echo / Fprintf)' };
|
|
297
306
|
}
|
|
@@ -144,6 +144,9 @@ export const NO_PROVENANCE_OVERRIDES = Object.freeze({
|
|
|
144
144
|
'py-os-getenv': 'env-value',
|
|
145
145
|
'py-os-environ': 'env-value',
|
|
146
146
|
'py-input': 'user-input',
|
|
147
|
+
// Haskell: a parameter of an exported function carries caller-supplied text or a customer record (no request is modelled)
|
|
148
|
+
'hs-src-caller-controlled-param': 'user-input',
|
|
149
|
+
'hs-lin-src-record-parameter': 'user-input',
|
|
147
150
|
// Java / Servlet
|
|
148
151
|
'java-request-getParameter': 'http-query',
|
|
149
152
|
'java-request-getHeader': 'http-header',
|
|
@@ -195,6 +195,18 @@ export const TRANSFORM_CATALOG = Object.freeze([
|
|
|
195
195
|
{ id: 'py-hashlib-md5', language: 'py', kind: 'hash', reversibility: 'irreversible', algorithm: 'md5', confidence: 'high', match: { type: 'member-call', object: 'hashlib', method: 'md5' }, evidence: "call to '{name}' — Python's stdlib hashlib; the callee name itself states the digest algorithm (recognized, not endorsed: MD5 is broken)", examples: ['hashlib.md5'] },
|
|
196
196
|
{ id: 'java-message-digest', language: 'java', kind: 'hash', reversibility: 'irreversible', algorithm: null, confidence: 'high', match: { type: 'member-call', object: 'MessageDigest', method: 'getInstance' }, evidence: "call to '{name}' — the JCA MessageDigest factory (the digest algorithm is an argument, not read here)", examples: ['MessageDigest.getInstance'] },
|
|
197
197
|
|
|
198
|
+
// Haskell (cryptonite / crypton / bcrypt / argon2 packages). A dotted callee is matched on its last two segments, so
|
|
199
|
+
// `Crypto.Hash.hashWith` is object `Hash`, method `hashWith`.
|
|
200
|
+
{ id: 'hs-crypto-hash', language: 'hs', kind: 'hash', reversibility: 'irreversible', algorithm: null, confidence: 'high', match: { type: 'member-call', object: 'Hash', method: 'hash' }, evidence: "call to '{name}', Crypto.Hash digest (the algorithm is a type or argument)", examples: ['Crypto.Hash.hash'] },
|
|
201
|
+
{ id: 'hs-crypto-hashwith', language: 'hs', kind: 'hash', reversibility: 'irreversible', algorithm: null, confidence: 'high', match: { type: 'member-call', object: 'Hash', method: 'hashWith' }, evidence: "call to '{name}', Crypto.Hash digest with an explicit algorithm value", examples: ['Crypto.Hash.hashWith'] },
|
|
202
|
+
{ id: 'hs-crypto-hashlazy', language: 'hs', kind: 'hash', reversibility: 'irreversible', algorithm: null, confidence: 'high', match: { type: 'member-call', object: 'Hash', method: 'hashlazy' }, evidence: "call to '{name}', Crypto.Hash digest of a lazy value", examples: ['Crypto.Hash.hashlazy'] },
|
|
203
|
+
{ id: 'hs-crypto-sha256', language: 'hs', kind: 'hash', reversibility: 'irreversible', algorithm: 'sha256', confidence: 'high', match: { type: 'member-call', object: 'SHA256', method: 'hash' }, evidence: "call to '{name}', Crypto.Hash.SHA256", examples: ['Crypto.Hash.SHA256.hash'] },
|
|
204
|
+
{ id: 'hs-crypto-sha512', language: 'hs', kind: 'hash', reversibility: 'irreversible', algorithm: 'sha512', confidence: 'high', match: { type: 'member-call', object: 'SHA512', method: 'hash' }, evidence: "call to '{name}', Crypto.Hash.SHA512", examples: ['Crypto.Hash.SHA512.hash'] },
|
|
205
|
+
{ id: 'hs-crypto-blake2', language: 'hs', kind: 'hash', reversibility: 'irreversible', algorithm: 'blake2', confidence: 'high', match: { type: 'member-call', object: 'Blake2b_256', method: 'hash' }, evidence: "call to '{name}', Crypto.Hash BLAKE2", examples: ['Crypto.Hash.Blake2b_256.hash'] },
|
|
206
|
+
{ id: 'hs-hmac', language: 'hs', kind: 'hash', reversibility: 'irreversible', algorithm: null, confidence: 'high', match: { type: 'member-call', object: 'HMAC', method: 'hmac' }, evidence: "call to '{name}', Crypto.MAC.HMAC keyed digest; still one-way", examples: ['Crypto.MAC.HMAC.hmac'] },
|
|
207
|
+
{ id: 'hs-bcrypt-hash', language: 'hs', kind: 'hash', reversibility: 'irreversible', algorithm: 'bcrypt', confidence: 'high', match: { type: 'member-call', object: 'BCrypt', method: 'hashPassword' }, evidence: "call to '{name}', Crypto.KDF.BCrypt", examples: ['Crypto.KDF.BCrypt.hashPassword'] },
|
|
208
|
+
{ id: 'hs-argon2-hash', language: 'hs', kind: 'hash', reversibility: 'irreversible', algorithm: 'argon2', confidence: 'high', match: { type: 'member-call', object: 'Argon2', method: 'hash' }, evidence: "call to '{name}', Crypto.KDF.Argon2", examples: ['Crypto.KDF.Argon2.hash'] },
|
|
209
|
+
|
|
198
210
|
// ─── encrypt — reversible by design ────────────────────────────────────
|
|
199
211
|
{ id: 'js-node-cipheriv', language: 'js', kind: 'encrypt', reversibility: 'reversible', algorithm: null, confidence: 'high', match: { type: 'member-call', object: 'crypto', method: 'createCipheriv' }, evidence: "call to '{name}' — Node's built-in symmetric cipher factory (the cipher suite is an argument, not read here)", examples: ['crypto.createCipheriv'] },
|
|
200
212
|
{ id: 'js-node-cipher-legacy', language: 'js', kind: 'encrypt', reversibility: 'reversible', algorithm: null, confidence: 'high', match: { type: 'member-call', object: 'crypto', method: 'createCipher' }, evidence: "call to '{name}' — Node's deprecated symmetric cipher factory (removed in Node 22; still present in legacy code)", examples: ['crypto.createCipher'] },
|
|
@@ -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,
|