@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
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,73 @@
|
|
|
9
9
|
> make the history less accurate, not more.
|
|
10
10
|
|
|
11
11
|
|
|
12
|
+
## 0.154.0 - Haskell and Nix/NixOS support, with every limit stated
|
|
13
|
+
|
|
14
|
+
Haskell and Nix/NixOS are now scanned by the same commands as every other language, with no compiler, no `nix` binary and no
|
|
15
|
+
network. This entry records what is **measured** and what is **not**; nothing here announces completion beyond the evidence.
|
|
16
|
+
|
|
17
|
+
- **Analysis.** Dependency-free Haskell and Nix parsers over SHA-256-pinned grammar tables; Haskell taint (SQL, command, path,
|
|
18
|
+
SSRF, HTML), weak crypto, logging, resource and web-route authentication rules; the NixOS effective-configuration resolver with
|
|
19
|
+
hardening, build-trust and store-secret rules; Haskell and Nix personal-data flows in the Data Flow Explorer; a Haskell
|
|
20
|
+
catalog generated from one model registry. Unresolved constructs, conditional options, stale inputs and missing feeds become
|
|
21
|
+
scan-health conditions, so a scan that could not check something is `partial`, never complete.
|
|
22
|
+
- **Supply chain and BOM.** Hackage components with PVP-aware advisory matching (a snapshot you provide), a resolved graph from a
|
|
23
|
+
fresh Cabal plan or Stack export, Nix flake inputs, an exported closure matched by upstream identity with patch evidence, and
|
|
24
|
+
AI-BOM entries for model calls and inference services. License data is absent for these records and is disclosed, not guessed.
|
|
25
|
+
- **Fixes.** Verified, tiered, reversible fixes for five Haskell shapes and for Nix effective settings, plus dependency-bound
|
|
26
|
+
and flake-input edits labelled as needing a re-resolve or re-lock. The planner now follows a flow into the callee that holds
|
|
27
|
+
the dangerous call. A fix a gate refuses is reported, not applied.
|
|
28
|
+
- **CLI reach.** `fix` and `undo` now work for these languages, `hunt` includes `.hs`/`.nix`, `--since-baseline` filters every
|
|
29
|
+
bucket (a baselined dependency finding no longer reappears as new), the edit hook catches credentials split across literals, and
|
|
30
|
+
a Cabal or Nix project is recognised as a project root for state.
|
|
31
|
+
- **Support, measured.** `docs/language-support.md` records per-capability status from a frozen holdout. On the synthetic,
|
|
32
|
+
template-generated QA-001 corpus every measured layer scores P/R/F1 100%, which says the engine is robust over those shapes and
|
|
33
|
+
**not** that it is accurate on arbitrary real code. `auth` (Haskell) and `nix-eval` / `nixos-host` (Nix) are **blocked**: no
|
|
34
|
+
`ghc`, `nix` or NixOS host was available where this was built.
|
|
35
|
+
- **Tooling.** The `npm test` gate now runs the Haskell, Nix and language scopes; `language-stress` and `language-tools` have
|
|
36
|
+
their own scripts. New CI jobs (`language-suites`, `language-tools-ghc`, `nixos-runtime`) carry explicit deadlines; a
|
|
37
|
+
`language-support-gate` joins pre-push and the release check. A bounded background-loop controller and runbook
|
|
38
|
+
(`docs/guides/loop-engineering.md`) are a development tool, not part of the package.
|
|
39
|
+
- **Docs.** New Haskell, Nix/NixOS, NixOS-install and loop guides; tables and example outputs are generated from the registries and
|
|
40
|
+
from the built bundle, and stale ones fail `npm run docs:check-language`.
|
|
41
|
+
- **Verified and not verified.** The Haskell route fixtures compile under GHC 9.4.8 and the Haskell support table is 9 of 9. NIX-011 (a
|
|
42
|
+
real controlled evaluation) and NIX-012 (the scanner on a NixOS host, VM checks, and the new `flake.nix`) need Nix and NixOS, which
|
|
43
|
+
were never available where this was built: they stay blocked rather than faked, and the flake has never been built. The CI jobs
|
|
44
|
+
that would exercise them are written but have not run.
|
|
45
|
+
|
|
46
|
+
## 0.153.2 - Scanner F1 benchmark and dependency-currency CI repaired
|
|
47
|
+
|
|
48
|
+
The daily `Scanner F1 benchmark` workflow had failed every run since about 2026-08-10. Root causes, each
|
|
49
|
+
reproduced locally before it was changed:
|
|
50
|
+
|
|
51
|
+
- **Code injection: sink names inside string literals** (`code-injection-multilang.js`). `eval(` quoted in a
|
|
52
|
+
rule library's `fix:`/`example:` text fired as a critical finding. Matches inside a `'...'` or `"..."`
|
|
53
|
+
literal are now skipped (backtick templates are not, since `${...}` holds real code).
|
|
54
|
+
- **ReDoS: overlapping alternation under a quantifier was rejected.** The mechanism gate added in 0.14x
|
|
55
|
+
admitted only nested quantifiers and leading unbounded ones, so `/^(a|aa)*$/` never reached the overlap
|
|
56
|
+
check. It is now a third named mechanism, and like a nested quantifier it is not cured by anchoring.
|
|
57
|
+
- **Go: package-level `http.Get/Post/Head/PostForm` have no timeout.** The old finding only appeared
|
|
58
|
+
because a JavaScript regex happened to match Go; it was correctly scoped to JS later and Go lost the
|
|
59
|
+
finding. A real Go rule (`go-http-no-timeout`) now covers it.
|
|
60
|
+
- **Benchmark scorers** no longer count info-level `stack-playbook:` checklist advice as detections,
|
|
61
|
+
treat an npm lockfile as the manifest for dependency findings (Nodegoat F1 had fallen 80.7% to 15.7%
|
|
62
|
+
on that alone), and carry ground truth for the ownership-authz and rate-limit detectors. The `clean-js`
|
|
63
|
+
fixture now actually checks ownership.
|
|
64
|
+
- **Real-world gate:** Juice Shop, Goof and NodeGoat have ground truth flagged `requiresReAudit`, which the
|
|
65
|
+
bench itself calls informational, so they are gated on recall (floor 75%, measured 81-83%) with F1 still
|
|
66
|
+
printed. OWASP Benchmark and Juliet keep their F1 floors.
|
|
67
|
+
- **SARD blind smoke:** the macro-F1 check now uses the support-floored macro. Zero-support rows that exist
|
|
68
|
+
only because a detector fired outside the scanned CWE subset were dragging down the mean, so a micro-F1
|
|
69
|
+
gain from 65.8% to 88.7% read as a macro regression. Precision remains gated separately.
|
|
70
|
+
- **Real-world bench truncation:** eight unfloored corpora (pygoat, django-clean, nextjs-clean, ...) contain
|
|
71
|
+
files over the engine's size caps, which it skips by design, and the bench read any skipped file as a failed
|
|
72
|
+
measurement, so the job exited 1 on every run. The workflow now passes `--allow-truncation` and instead fails
|
|
73
|
+
any app that HAS a floor if it was truncated, so the gating apps still fail closed.
|
|
74
|
+
- **Flaky provenance test:** `a budget_exhausted result is NEVER cached` raced a 5ms budget against git's
|
|
75
|
+
latency and failed on a fast hosted runner. It now uses a 1ms budget and retries until a run lands on the
|
|
76
|
+
path under test, still failing loudly if none does.
|
|
77
|
+
- `@types/node` 26.6.3 to 26.6.4 in `scanner/` and `ide/vscode/` for the dependency-currency gate.
|
|
78
|
+
|
|
12
79
|
## 0.153.1 - CI hygiene: off the deprecated Node 20 Actions runtime
|
|
13
80
|
|
|
14
81
|
A maintenance-only release, no engine or scanner behavior changes.
|
package/bin/agentic-security.js
CHANGED
|
@@ -50,9 +50,11 @@ import { toOSCAL } from '../src/report/oscal.js';
|
|
|
50
50
|
// format had to be added to all three or it silently got human chatter
|
|
51
51
|
// interleaved into its stdout/stderr. One list, one place to update.
|
|
52
52
|
const MACHINE_FORMATS = new Set([
|
|
53
|
-
'json', 'sarif', 'oscal', 'cyclonedx', 'sbom', 'spdx', 'vex', 'openvex', 'pbom', 'aibom',
|
|
53
|
+
'json', 'sarif', 'oscal', 'cyclonedx', 'sbom', 'spdx', 'vex', 'openvex', 'pbom', 'aibom', 'mlbom',
|
|
54
54
|
]);
|
|
55
55
|
function isMachineFormat(fmt) { return MACHINE_FORMATS.has(String(fmt)); }
|
|
56
|
+
// Every output label `scan` produces (the body of cmdScan has one branch per entry).
|
|
57
|
+
const SCAN_FORMATS = new Set(['cli', 'json', 'md', 'markdown', 'sarif', 'oscal', 'stix', 'junit', 'csv', 'html', 'cyclonedx', 'sbom', 'spdx', 'vex', 'openvex', 'pbom', 'aibom', 'aibom-md', 'mlbom', 'ship', 'pro', 'summary']);
|
|
56
58
|
|
|
57
59
|
// Shared stderr progress reporter for any command that runs a fresh scan —
|
|
58
60
|
// the same `\r[phase] current/total file` status line the default `scan`
|
|
@@ -75,7 +77,7 @@ function clearScanProgressLine() {
|
|
|
75
77
|
}
|
|
76
78
|
import { toCycloneDX, toSPDX } from '../src/posture/sbom.js';
|
|
77
79
|
import { toPBOM } from '../src/sast/pipeline.js';
|
|
78
|
-
import { buildAIBOM, aibomToMarkdown } from '../src/posture/aibom.js';
|
|
80
|
+
import { buildAIBOM, aibomToMarkdown, toCycloneDXMLBOM } from '../src/posture/aibom.js';
|
|
79
81
|
import { recordScan, formatStreakLine, formatGradeDelta } from '../src/posture/streak.js';
|
|
80
82
|
import { loadProfile, saveProfile, detectProfile, renderAttributionLine, ATTRIBUTION, ATTRIBUTION_URL } from '../src/posture/profile.js';
|
|
81
83
|
import { applySuppressions, addSoftAcceptance, expiredSoftAcceptances } from '../src/posture/suppressions.js';
|
|
@@ -93,6 +95,16 @@ import * as triage from '../src/posture/triage.js';
|
|
|
93
95
|
import { buildSlackDigest, buildDiscordDigest, postWebhook, buildJiraIssue, buildPrComment, buildSiemEvent, loadIntegrationConfig } from '../src/integrations/index.js';
|
|
94
96
|
|
|
95
97
|
import { stateDir, statePath, withStateWritesDisabled } from '../src/posture/state-dir.js';
|
|
98
|
+
|
|
99
|
+
// A state file is never written THROUGH a symlink: a hostile tree could place a link at `.agentic-security/last-scan.json` and have a
|
|
100
|
+
// scan overwrite whatever it points to. O_NOFOLLOW makes the open itself refuse a link (no check-then-write race); where the flag does
|
|
101
|
+
// not exist an lstat check stands in for it.
|
|
102
|
+
const _NOFOLLOW_FLAGS = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_TRUNC | (fs.constants.O_NOFOLLOW || 0);
|
|
103
|
+
async function _writeStateNoFollow(file, body) {
|
|
104
|
+
if (!fs.constants.O_NOFOLLOW) { try { if ((await fsp.lstat(file)).isSymbolicLink()) return false; } catch { /* absent */ } }
|
|
105
|
+
try { await fsp.writeFile(file, body, { flag: _NOFOLLOW_FLAGS, mode: 0o644 }); return true; }
|
|
106
|
+
catch (e) { if (e && (e.code === 'ELOOP' || e.code === 'EMLINK')) return false; throw e; }
|
|
107
|
+
}
|
|
96
108
|
import { listGeneratedArtifacts } from '../src/posture/artifact-registry.js';
|
|
97
109
|
// last-scan.json integrity helpers — implementation in posture/integrity.js
|
|
98
110
|
// so the MCP server tools can share verification.
|
|
@@ -194,6 +206,8 @@ Commands:
|
|
|
194
206
|
--remote-node <node-id> [--repository <label>]
|
|
195
207
|
[--relationship data_flow] [--rationale <text>]
|
|
196
208
|
[--output <file>] [--yes] [--base-digest <hex>]
|
|
209
|
+
[--local-target <t>] [--local-config <c>]
|
|
210
|
+
[--remote-target <t>] [--remote-config <c>]
|
|
197
211
|
Declare a CrossRepoLink between a node in the
|
|
198
212
|
current locally-scanned graph and a node in a
|
|
199
213
|
remote graph export (dataflow export --format
|
|
@@ -234,7 +248,7 @@ Commands:
|
|
|
234
248
|
Options:
|
|
235
249
|
--profile vibecoder|pro Override profile for this run
|
|
236
250
|
--only sast|sca|secrets Limit scan to one pillar
|
|
237
|
-
--format <fmt> cli | json | md | sarif | oscal | stix | junit | csv | html | cyclonedx | spdx | pbom | aibom | aibom-md
|
|
251
|
+
--format <fmt> cli | json | md | sarif | oscal | stix | junit | csv | html | cyclonedx | spdx | pbom | aibom | aibom-md | mlbom
|
|
238
252
|
--pack <name> Focus on a curated rule pack (repeatable): owasp-top-10 | cwe-top-25 | llm-security | supply-chain
|
|
239
253
|
--baseline <ref> Diff against a git ref; only findings new vs. that ref count (ci subcommand)
|
|
240
254
|
--fail-on critical|high|medium|low|none ci-mode exit policy (default: critical)
|
|
@@ -470,12 +484,12 @@ async function writeMachineOutput(targetAbs, scan, meta, profile, args) {
|
|
|
470
484
|
}
|
|
471
485
|
await fsp.mkdir(stateDirPath, { recursive: true });
|
|
472
486
|
// Always JSON (used by /security-fix and /security-report).
|
|
473
|
-
await
|
|
487
|
+
await _writeStateNoFollow(path.join(stateDirPath, 'findings.json'),
|
|
474
488
|
JSON.stringify(toJSON(scan, meta), null, 2));
|
|
475
489
|
if (profile.profile === 'pro' || profile.machineOutput || (args && args.flags['machine-output'])) {
|
|
476
|
-
await
|
|
490
|
+
await _writeStateNoFollow(path.join(stateDirPath, 'findings.sarif'),
|
|
477
491
|
JSON.stringify(toSARIF(scan, meta), null, 2));
|
|
478
|
-
await
|
|
492
|
+
await _writeStateNoFollow(path.join(stateDirPath, 'findings.csv'), toCSV(scan));
|
|
479
493
|
}
|
|
480
494
|
}
|
|
481
495
|
|
|
@@ -613,6 +627,12 @@ async function cmdScan(args) {
|
|
|
613
627
|
// Load persona profile (R1). Persona-aware defaults flow from here.
|
|
614
628
|
const profile = loadPersonaProfile(targetAbs, args);
|
|
615
629
|
const format = args.flags.format || (profile.profile === 'pro' ? 'cli' : 'ship');
|
|
630
|
+
// An output label this command does not produce is an error, not a quiet fallback to the summary: a CI job that
|
|
631
|
+
// asked for `sarif ` or `junit-xml` and got human text would upload garbage and report success.
|
|
632
|
+
if (!SCAN_FORMATS.has(String(format))) {
|
|
633
|
+
process.stderr.write(`agentic-security scan: unsupported --format ${JSON.stringify(String(format))}. Supported: ${[...SCAN_FORMATS].join(' | ')}.\n`);
|
|
634
|
+
return 2;
|
|
635
|
+
}
|
|
616
636
|
const verbose = !!args.flags.verbose;
|
|
617
637
|
const output = args.flags.output;
|
|
618
638
|
const noNet = !!args.flags['no-network'];
|
|
@@ -782,6 +802,9 @@ async function cmdScan(args) {
|
|
|
782
802
|
if (args.flags['set-baseline']) {
|
|
783
803
|
const { normalizeFindings } = await import('../src/report/index.js');
|
|
784
804
|
const baselineIds = new Set(normalizeFindings(scan).map(f => f.stableId || f.id));
|
|
805
|
+
// A supply-chain, secret or logic finding's normalised id includes its line, so an unrelated edit above it would make it
|
|
806
|
+
// look new. Its own producer id (when it has one) does not, so both are recorded.
|
|
807
|
+
for (const bucket of ['secrets', 'supplyChain', 'logicVulns']) for (const f of scan[bucket] || []) if (f && typeof f.id === 'string' && f.id.includes(':')) baselineIds.add(f.id);
|
|
785
808
|
fs.mkdirSync(path.dirname(baselinePath), { recursive: true });
|
|
786
809
|
fs.writeFileSync(baselinePath, JSON.stringify({ ids: [...baselineIds], createdAt: new Date().toISOString(), count: baselineIds.size }, null, 2));
|
|
787
810
|
process.stderr.write(`[baseline] saved ${baselineIds.size} findings as baseline\n`);
|
|
@@ -793,6 +816,20 @@ async function cmdScan(args) {
|
|
|
793
816
|
const baselineSet = new Set(baseline.ids || []);
|
|
794
817
|
const before = scan.findings.length;
|
|
795
818
|
scan.findings = (scan.findings || []).filter(f => !baselineSet.has(f.stableId || f.id));
|
|
819
|
+
// The baseline is built from every bucket (findings, secrets, supply chain, logic), so it must be applied to every bucket:
|
|
820
|
+
// filtering only `findings` left a baselined dependency or secret finding reappearing as "new" on every run.
|
|
821
|
+
// Identity is the one the baseline was written with: the NORMALISED id (raw supply-chain entries carry a different one).
|
|
822
|
+
const { normalizeFindings: _norm } = await import('../src/report/index.js');
|
|
823
|
+
const _empty = { findings: [], secrets: [], supplyChain: [], logicVulns: [], suppressions: [], components: [], routes: [], filesScanned: 0 };
|
|
824
|
+
for (const bucket of ['secrets', 'supplyChain', 'logicVulns']) {
|
|
825
|
+
if (!Array.isArray(scan[bucket])) continue;
|
|
826
|
+
scan[bucket] = scan[bucket].filter((f) => {
|
|
827
|
+
if (!f) return true;
|
|
828
|
+
let id = f.stableId || f.id;
|
|
829
|
+
try { const n = _norm({ ..._empty, [bucket]: [f] })[0]; if (n) id = n.stableId || n.id; } catch { /* keep the raw identity */ }
|
|
830
|
+
return !baselineSet.has(id) && !(typeof f.id === 'string' && f.id.includes(':') && baselineSet.has(f.id));
|
|
831
|
+
});
|
|
832
|
+
}
|
|
796
833
|
process.stderr.write(`[baseline] filtered ${before - scan.findings.length} baseline findings, ${scan.findings.length} new\n`);
|
|
797
834
|
} catch { /* baseline file unreadable, skip */ }
|
|
798
835
|
}
|
|
@@ -997,9 +1034,10 @@ async function cmdScan(args) {
|
|
|
997
1034
|
else if (format === 'cyclonedx' || format === 'sbom') body = JSON.stringify(toCycloneDX(scan, meta), null, 2);
|
|
998
1035
|
else if (format === 'spdx') body = JSON.stringify(toSPDX(scan, meta), null, 2);
|
|
999
1036
|
else if (format === 'vex' || format === 'openvex') body = JSON.stringify(toVex(scan, meta), null, 2);
|
|
1000
|
-
else if (format === 'pbom') body = JSON.stringify(toPBOM(scan.fc || {}, meta), null, 2);
|
|
1001
|
-
else if (format === 'aibom') body = JSON.stringify(buildAIBOM(scan, scan.fc || {}, meta), null, 2);
|
|
1002
|
-
else if (format === '
|
|
1037
|
+
else if (format === 'pbom') body = JSON.stringify(toPBOM(scan.fc || {}, meta, { languageBuild: (await import('../src/language/bom.js')).languagePbom({ ...(scan.fc || {}), ...(scan.depFileContents || {}) }) }), null, 2);
|
|
1038
|
+
else if (format === 'aibom') body = JSON.stringify(buildAIBOM(scan, scan.fc || {}, { ...meta, manifests: scan.depFileContents || {} }), null, 2);
|
|
1039
|
+
else if (format === 'mlbom') body = JSON.stringify(toCycloneDXMLBOM(buildAIBOM(scan, scan.fc || {}, { ...meta, manifests: scan.depFileContents || {} }), meta), null, 2);
|
|
1040
|
+
else if (format === 'aibom-md') body = aibomToMarkdown(buildAIBOM(scan, scan.fc || {}, { ...meta, manifests: scan.depFileContents || {} }));
|
|
1003
1041
|
else if (format === 'ship') body = toShipVerdict(scan, { profile: effProfile });
|
|
1004
1042
|
else if (format === 'pro') body = toProTable(scan, { profile: effProfile, columns: args.flags.columns });
|
|
1005
1043
|
else if (format === 'cli') body = toCLIByProfile(scan, { profile: effProfile, columns: args.flags.columns, verbose });
|
|
@@ -1143,10 +1181,16 @@ async function cmdScan(args) {
|
|
|
1143
1181
|
} catch { /* best-effort, offline-degrading — never block a scan */ }
|
|
1144
1182
|
}
|
|
1145
1183
|
const lastScanBody = JSON.stringify(persistedScan, null, 2);
|
|
1146
|
-
await
|
|
1184
|
+
await _writeStateNoFollow(path.join(stateDirPath, 'last-scan.json'), lastScanBody);
|
|
1147
1185
|
try {
|
|
1148
|
-
await
|
|
1186
|
+
await _writeStateNoFollow(path.join(stateDirPath, 'last-scan.json.sig'), _signLastScan(lastScanBody));
|
|
1149
1187
|
} catch { /* non-fatal — sig file is best-effort */ }
|
|
1188
|
+
// X-016: Haskell/Nix analysis metadata and BOM as registered, signed artifacts. Written only when the scan has
|
|
1189
|
+
// Haskell or Nix content, so every other project's state directory is unchanged.
|
|
1190
|
+
try {
|
|
1191
|
+
const { persistLanguageArtifacts } = await import('../src/language/state-artifacts.js');
|
|
1192
|
+
persistLanguageArtifacts(path.resolve(target), { ...scan, scanHealth: persistedScan.scanHealth || scan.scanHealth });
|
|
1193
|
+
} catch { /* non-fatal: metadata artifacts never block a scan */ }
|
|
1150
1194
|
// Sub-project E, increment 5: persist the lineage graph as its own
|
|
1151
1195
|
// artifact, mirroring last-scan.json's own write+sign pattern exactly
|
|
1152
1196
|
// — signLastScan/verifyLastScan are fully generic (an arbitrary string
|
|
@@ -1158,9 +1202,9 @@ async function cmdScan(args) {
|
|
|
1158
1202
|
if (scan.lineageGraph) {
|
|
1159
1203
|
try {
|
|
1160
1204
|
const lineageBody = JSON.stringify(scan.lineageGraph, null, 2);
|
|
1161
|
-
await
|
|
1205
|
+
await _writeStateNoFollow(path.join(stateDirPath, 'lineage-graph.json'), lineageBody);
|
|
1162
1206
|
try {
|
|
1163
|
-
await
|
|
1207
|
+
await _writeStateNoFollow(path.join(stateDirPath, 'lineage-graph.json.sig'), _signLastScan(lineageBody));
|
|
1164
1208
|
} catch { /* non-fatal — sig file is best-effort, same precedent as last-scan.json.sig above */ }
|
|
1165
1209
|
} catch { /* non-fatal — the lineage artifact write is best-effort and must never block a scan */ }
|
|
1166
1210
|
// M4 deliverable #8 (FR-503 §14, DFG-022, sub-project 8a): ADDITIVELY
|
|
@@ -1301,11 +1345,11 @@ async function cmdCi(args) {
|
|
|
1301
1345
|
if (process.env.AGENTIC_SECURITY_DEBUG === '1') process.stderr.write(`[agentic-security] refusing to write CI artifacts at ${stateDirPath} — no project marker\n`);
|
|
1302
1346
|
} else {
|
|
1303
1347
|
await fsp.mkdir(stateDirPath, { recursive: true });
|
|
1304
|
-
await
|
|
1348
|
+
await _writeStateNoFollow(path.join(stateDirPath, 'findings.json'),
|
|
1305
1349
|
JSON.stringify(toJSON(scan, meta), null, 2));
|
|
1306
|
-
await
|
|
1350
|
+
await _writeStateNoFollow(path.join(stateDirPath, 'findings.sarif'),
|
|
1307
1351
|
JSON.stringify(toSARIF(scan, meta), null, 2));
|
|
1308
|
-
await
|
|
1352
|
+
await _writeStateNoFollow(path.join(stateDirPath, 'findings.junit.xml'),
|
|
1309
1353
|
toJUnit(scan, meta));
|
|
1310
1354
|
}
|
|
1311
1355
|
|
|
@@ -1534,7 +1578,7 @@ async function cmdTriage(args) {
|
|
|
1534
1578
|
const lastScanPath = statePath(target, 'findings.json');
|
|
1535
1579
|
if (fs.existsSync(lastScanPath)) {
|
|
1536
1580
|
const last = JSON.parse(await fsp.readFile(lastScanPath, 'utf8'));
|
|
1537
|
-
triage.syncWithScan(target, last.findings || []);
|
|
1581
|
+
triage.syncWithScan(target, last.findings || [], { scanHealth: last.scanHealth || null });
|
|
1538
1582
|
}
|
|
1539
1583
|
if (sub === 'list') {
|
|
1540
1584
|
const filter = {};
|
|
@@ -2587,8 +2631,8 @@ async function cmdCalibrationReport(args) {
|
|
|
2587
2631
|
// Languages the Layer-1 IR parses. Anything else cannot be partitioned into a
|
|
2588
2632
|
// call-graph focus area, so feeding it to a hunter would spend tokens on files
|
|
2589
2633
|
// the confirmation gate can never corroborate.
|
|
2590
|
-
const HUNT_EXTS = /\.(?:js|jsx|mjs|cjs|ts|tsx|py|java|cs|kt|go|php|rb)$/i;
|
|
2591
|
-
const HUNT_IGNORE = ['node_modules/**', '.git/**', 'dist/**', 'build/**', 'vendor/**', '**/.agentic-security/**'];
|
|
2634
|
+
const HUNT_EXTS = /\.(?:js|jsx|mjs|cjs|ts|tsx|py|java|cs|kt|go|php|rb|l?hs|nix)$/i;
|
|
2635
|
+
const HUNT_IGNORE = ['node_modules/**', '.git/**', 'dist/**', 'build/**', 'vendor/**', '**/.agentic-security/**', 'dist-newstyle/**', '.stack-work/**', '.direnv/**'];
|
|
2592
2636
|
const HUNT_MAX_FILES = 2000;
|
|
2593
2637
|
|
|
2594
2638
|
async function cmdHunt(args) {
|
|
@@ -3408,6 +3452,41 @@ async function cmdFix(args) {
|
|
|
3408
3452
|
return 0;
|
|
3409
3453
|
}
|
|
3410
3454
|
|
|
3455
|
+
// Haskell and Nix findings have their own verified fixers (tiered, rescanned, reversible by `undo`); the generic path below
|
|
3456
|
+
// knows nothing about them and used to answer "no mechanical fix" for every one.
|
|
3457
|
+
{
|
|
3458
|
+
const { languageOfFinding, languageFixPreview, loadLanguageProject, isLanguageDependencyFinding, languageUpgradePreview } = await import('../src/language/context.js');
|
|
3459
|
+
if (isLanguageDependencyFinding(f)) {
|
|
3460
|
+
if (isApply && sigVerified !== true) {
|
|
3461
|
+
console.error(`Refusing to apply: last-scan.json integrity check ${sigVerified === false ? 'failed (tampered)' : 'could not verify (unsigned)'} — re-run \`agentic-security scan\` to refresh.`);
|
|
3462
|
+
return 4;
|
|
3463
|
+
}
|
|
3464
|
+
const proj = loadLanguageProject(scanRoot);
|
|
3465
|
+
const res = await languageUpgradePreview(f, { ...proj.files, ...proj.manifests }, { apply: isApply, root: scanRoot, toRef: args.flags.to || null });
|
|
3466
|
+
if (!res.ok) { console.error(`No dependency fix for this finding (${res.status}${res.tier ? `, ${res.tier}` : ''}): ${res.reason || 'unsupported'}`); return 4; }
|
|
3467
|
+
console.log(`${res.label} (${res.tier}): ${res.explanation}`);
|
|
3468
|
+
console.log(res.diff);
|
|
3469
|
+
for (const c of res.consequences || []) console.log(` note: ${c}`);
|
|
3470
|
+
console.log(isApply ? '\nApplied. This is a source edit only: re-resolve or re-lock, then rescan. Revert with `agentic-security undo`.' : '\nRun with --apply to write this change. Use `agentic-security undo` to revert.');
|
|
3471
|
+
return 0;
|
|
3472
|
+
}
|
|
3473
|
+
if (languageOfFinding(f)) {
|
|
3474
|
+
if (isApply && sigVerified !== true) {
|
|
3475
|
+
console.error(`Refusing to apply: last-scan.json integrity check ${sigVerified === false ? 'failed (tampered)' : 'could not verify (unsigned)'} — re-run \`agentic-security scan\` to refresh.`);
|
|
3476
|
+
return 4;
|
|
3477
|
+
}
|
|
3478
|
+
const proj = loadLanguageProject(scanRoot);
|
|
3479
|
+
const res = await languageFixPreview(f, proj.files, { apply: isApply, root: scanRoot });
|
|
3480
|
+
if (!res.ok) { console.error(`No verified fix for this finding (${res.status}${res.tier ? `, ${res.tier}` : ''}): ${res.reason || 'unsupported'}`); return 4; }
|
|
3481
|
+
console.log(`${res.label || 'FIX'}${res.tier ? ` (${res.tier})` : ''}: ${res.explanation || ''}`.trim());
|
|
3482
|
+
console.log(res.diff);
|
|
3483
|
+
for (const c of res.consequences || []) console.log(` note: ${c}`);
|
|
3484
|
+
if (isApply) { console.log(`\nApplied. Verified: ${Object.entries(res.gates || {}).map(([k, v]) => `${k}=${v && v.ok === false ? 'FAILED' : (v && v.ran === false ? 'not-run' : 'ok')}`).join(' ')}. Revert with \`agentic-security undo\`.`); }
|
|
3485
|
+
else console.log('\nVerified (syntax, rescan: the finding is gone and nothing new appears). Run with --apply to write this change. Use `agentic-security undo` to revert.');
|
|
3486
|
+
return 0;
|
|
3487
|
+
}
|
|
3488
|
+
}
|
|
3489
|
+
|
|
3411
3490
|
// FR-303 (assurance-hardening PRD): confine BEFORE the first read, not just
|
|
3412
3491
|
// before the write — a traversal or symlink-planted f.file was previously
|
|
3413
3492
|
// followed unquestioned for both the preview diff and the apply write (no
|
|
@@ -5558,6 +5637,21 @@ async function cmdGovernancePropose(args) {
|
|
|
5558
5637
|
// through to main()'s own outer catch/process.exit(4), the identical,
|
|
5559
5638
|
// deliberate non-pattern cmdGovernancePropose itself relies on (no local
|
|
5560
5639
|
// try/catch here either).
|
|
5640
|
+
function _federateEndpointScope(node, target, config, graph) {
|
|
5641
|
+
// Node-level limitations when present, plus those of every flow that starts or ends at the node: a Nix or Haskell
|
|
5642
|
+
// flow states its own limits (static configuration evidence, generated-script locations) and a link must not lose them.
|
|
5643
|
+
const lim = new Set(Array.isArray(node && node.limitations) ? node.limitations.filter((x) => typeof x === 'string') : []);
|
|
5644
|
+
for (const f of (graph && Array.isArray(graph.flows) ? graph.flows : [])) {
|
|
5645
|
+
if (f && (f.source === (node && node.id) || f.sink === (node && node.id)) && Array.isArray(f.limitations)) for (const x of f.limitations) if (typeof x === 'string') lim.add(x);
|
|
5646
|
+
}
|
|
5647
|
+
return {
|
|
5648
|
+
target: typeof target === 'string' ? target : null,
|
|
5649
|
+
config: typeof config === 'string' ? config : null,
|
|
5650
|
+
analysis: (node && node.analysis && typeof node.analysis === 'object') ? node.analysis : null,
|
|
5651
|
+
limitations: [...lim].sort(),
|
|
5652
|
+
};
|
|
5653
|
+
}
|
|
5654
|
+
|
|
5561
5655
|
async function cmdFederateDeclare(args) {
|
|
5562
5656
|
const target = args._[2] || '.'; // args._ = ['federate', 'declare', <path>?]
|
|
5563
5657
|
const targetAbs = path.resolve(target);
|
|
@@ -5680,6 +5774,14 @@ async function cmdFederateDeclare(args) {
|
|
|
5680
5774
|
sourceFile: path.resolve(remoteGraphFlag),
|
|
5681
5775
|
graphId: remote.graph.graphId, graphDigest: remote.digest, nodeId: remoteNodeFlag,
|
|
5682
5776
|
},
|
|
5777
|
+
// Scope and limitations of both endpoints, copied from the nodes themselves (a Haskell/Nix configuration node says it
|
|
5778
|
+
// is static configuration evidence), plus the operator's own target/config labels. A declared link is an assertion by
|
|
5779
|
+
// an operator, never an observed data flow, and the record says so.
|
|
5780
|
+
scope: {
|
|
5781
|
+
local: _federateEndpointScope(localNode, args.flags['local-target'], args.flags['local-config'], local.graph),
|
|
5782
|
+
remote: _federateEndpointScope(remoteNode, args.flags['remote-target'], args.flags['remote-config'], remote.graph),
|
|
5783
|
+
},
|
|
5784
|
+
evidence: { declared: true, observed: false },
|
|
5683
5785
|
rationale: args.flags.rationale ?? null,
|
|
5684
5786
|
declaredBy: process.env.USER || process.env.USERNAME || '(unspecified)',
|
|
5685
5787
|
declaredAt: new Date().toISOString(),
|
|
@@ -5798,6 +5900,8 @@ async function cmdFederateList(args) {
|
|
|
5798
5900
|
id: record?.id ?? null,
|
|
5799
5901
|
local: { nodeId: record?.local?.nodeId ?? null, stillValid: localStillValid },
|
|
5800
5902
|
remote: { sourceFile: sourceFile ?? null, nodeId: record?.remote?.nodeId ?? null, ...remoteStatus },
|
|
5903
|
+
scope: record?.scope ?? null,
|
|
5904
|
+
evidence: record?.evidence ?? { declared: true, observed: false },
|
|
5801
5905
|
rationale: record?.rationale ?? null,
|
|
5802
5906
|
declaredBy: record?.declaredBy ?? null,
|
|
5803
5907
|
declaredAt: record?.declaredAt ?? null,
|
package/dist/1444.index.js
CHANGED
|
@@ -8,9 +8,10 @@ export const modules = {
|
|
|
8
8
|
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
|
|
9
9
|
/* harmony export */ sweepGitHistory: () => (/* binding */ sweepGitHistory)
|
|
10
10
|
/* harmony export */ });
|
|
11
|
-
/* unused harmony exports extractAddedLines, scanHistoryDiff */
|
|
11
|
+
/* unused harmony exports extractAddedLines, splitDiffByFile, scanHistoryDiff */
|
|
12
12
|
/* harmony import */ var node_child_process__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(31421);
|
|
13
13
|
/* harmony import */ var _util_git_hardening_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(78844);
|
|
14
|
+
/* harmony import */ var _language_secrets_js__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(95108);
|
|
14
15
|
// R15 (PRD §5) — git-history secret sweep.
|
|
15
16
|
//
|
|
16
17
|
// A secret removed from HEAD but present in any past commit is still
|
|
@@ -27,6 +28,7 @@ export const modules = {
|
|
|
27
28
|
|
|
28
29
|
|
|
29
30
|
|
|
31
|
+
|
|
30
32
|
// Pull the post-image (added) lines out of a unified diff: lines starting with
|
|
31
33
|
// a single '+' (not the '+++' file header). Returns reconstructed text.
|
|
32
34
|
function extractAddedLines(diffText) {
|
|
@@ -37,25 +39,42 @@ function extractAddedLines(diffText) {
|
|
|
37
39
|
return out.join('\n');
|
|
38
40
|
}
|
|
39
41
|
|
|
42
|
+
// Split a unified diff into per-file sections with the POST-IMAGE line number of every added line, so a
|
|
43
|
+
// finding can name the file and line it was committed at (the combined text alone loses both).
|
|
44
|
+
function splitDiffByFile(diffText) {
|
|
45
|
+
const files = [];
|
|
46
|
+
let cur = null; let next = 0;
|
|
47
|
+
for (const line of String(diffText || '').split('\n')) {
|
|
48
|
+
const h = /^diff --git a\/(.+?) b\/(.+)$/.exec(line);
|
|
49
|
+
if (h) { cur = { path: h[2], added: [] }; files.push(cur); next = 0; continue; }
|
|
50
|
+
if (!cur) continue;
|
|
51
|
+
const hh = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line);
|
|
52
|
+
if (hh) { next = parseInt(hh[1], 10); continue; }
|
|
53
|
+
if (line.startsWith('+++') || line.startsWith('---')) continue;
|
|
54
|
+
if (line.startsWith('+')) { cur.added.push({ text: line.slice(1), line: next }); next++; }
|
|
55
|
+
else if (!line.startsWith('-') && !line.startsWith('\\')) next++;
|
|
56
|
+
}
|
|
57
|
+
return files;
|
|
58
|
+
}
|
|
59
|
+
|
|
40
60
|
// Pure: run the injected credential detector over a commit's added lines.
|
|
41
|
-
// detectFn has the scanCredentials(fp, raw) shape and returns Finding[].
|
|
42
|
-
|
|
61
|
+
// detectFn has the scanCredentials(fp, raw) shape and returns Finding[]. `extra` are additional detectors
|
|
62
|
+
// ((path, text) => Finding[]) run against each file's added lines WITH the real path, so extension-gated
|
|
63
|
+
// detectors (Haskell/Nix split-secret) apply to history too.
|
|
64
|
+
function scanHistoryDiff(diffText, commit, detectFn, extra = []) {
|
|
43
65
|
if (typeof detectFn !== 'function') return [];
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
...f,
|
|
66
|
+
const sections = splitDiffByFile(diffText);
|
|
67
|
+
const remediation = 'Rotate the credential now, then purge it from history (git filter-repo / BFG) and move it to a secrets manager. Removing it from HEAD alone is insufficient.';
|
|
68
|
+
const wrap = (f, sec, addedLines) => {
|
|
69
|
+
const hit = Number.isInteger(f.line) && f.line >= 1 ? addedLines[f.line - 1] : null;
|
|
70
|
+
const prov = f.rotation ? {} : (0,_language_secrets_js__WEBPACK_IMPORTED_MODULE_2__/* .providerInfo */ .cf)(f._rawProviderValue || '');
|
|
71
|
+
const out = {
|
|
72
|
+
...f, ...prov,
|
|
52
73
|
id: `secret-history:${commit}:${f.id || f.vuln || 'secret'}`,
|
|
53
|
-
file: `git-history@${commit}`,
|
|
54
|
-
line:
|
|
55
|
-
commit,
|
|
56
|
-
_historical: true,
|
|
74
|
+
file: `git-history@${commit}`, line: 0, commit, _historical: true,
|
|
75
|
+
...(sec ? { sourceFile: sec.path, sourceLine: hit ? hit.line : null } : {}),
|
|
57
76
|
vuln: `${f.vuln || 'Hardcoded Secret'} (in git history)`,
|
|
58
|
-
description: `${f.description || 'A credential was committed.'} Found in commit ${commit}; even if removed from HEAD it remains recoverable from git and must be rotated.`,
|
|
77
|
+
description: `${f.description || 'A credential was committed.'} Found in commit ${commit}${sec ? ` (${sec.path}${hit ? `:${hit.line}` : ''})` : ''}; even if removed from HEAD it remains recoverable from git and must be rotated.`,
|
|
59
78
|
remediation,
|
|
60
79
|
// report/index.js's _remediationOf checks `.fix` before `.remediation`
|
|
61
80
|
// — the underlying detector already set `.fix` to a generic "remove
|
|
@@ -64,15 +83,41 @@ function scanHistoryDiff(diffText, commit, detectFn) {
|
|
|
64
83
|
// insufficient") in every report format.
|
|
65
84
|
fix: remediation,
|
|
66
85
|
};
|
|
67
|
-
|
|
86
|
+
delete out._rawProviderValue;
|
|
87
|
+
return out;
|
|
88
|
+
};
|
|
89
|
+
const results = [];
|
|
90
|
+
if (!sections.length) {
|
|
91
|
+
const added = extractAddedLines(diffText);
|
|
92
|
+
if (!added.trim()) return [];
|
|
93
|
+
let findings = [];
|
|
94
|
+
try { findings = detectFn(`git-history@${commit}`, added) || []; } catch { return []; }
|
|
95
|
+
return findings.map((f) => wrap(f, null, []));
|
|
96
|
+
}
|
|
97
|
+
for (const sec of sections) {
|
|
98
|
+
if (!sec.added.length) continue;
|
|
99
|
+
const text = sec.added.map((a) => a.text).join('\n');
|
|
100
|
+
if (!text.trim()) continue;
|
|
101
|
+
let findings = [];
|
|
102
|
+
try { findings = detectFn(`git-history@${commit}`, text) || []; } catch { findings = []; }
|
|
103
|
+
for (const f of findings) results.push(wrap(f, sec, sec.added));
|
|
104
|
+
for (const ex of extra || []) {
|
|
105
|
+
let more = [];
|
|
106
|
+
try { more = ex(sec.path, text) || []; } catch { more = []; }
|
|
107
|
+
for (const f of more) results.push(wrap(f, sec, sec.added));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return results;
|
|
68
111
|
}
|
|
69
112
|
|
|
113
|
+
const defaultExtra = [_language_secrets_js__WEBPACK_IMPORTED_MODULE_2__/* .scanLanguageSecretConcat */ ._d, _language_secrets_js__WEBPACK_IMPORTED_MODULE_2__/* .scanDependencyUrlCredentials */ .y6];
|
|
114
|
+
|
|
70
115
|
/**
|
|
71
116
|
* Sweep up to `maxCommits` of recent history for secrets. Best-effort: returns
|
|
72
117
|
* [] when `scanRoot` is not a git repo or git is unavailable. Dedups a secret
|
|
73
118
|
* that recurs across commits to its earliest sighting.
|
|
74
119
|
*/
|
|
75
|
-
function sweepGitHistory(scanRoot, detectFn, { maxCommits = 50, timeoutMs = 20000 } = {}) {
|
|
120
|
+
function sweepGitHistory(scanRoot, detectFn, { maxCommits = 50, timeoutMs = 20000, extraDetectors = null } = {}) {
|
|
76
121
|
if (!scanRoot || typeof detectFn !== 'function') return [];
|
|
77
122
|
let out;
|
|
78
123
|
try {
|
|
@@ -91,7 +136,7 @@ function sweepGitHistory(scanRoot, detectFn, { maxCommits = 50, timeoutMs = 2000
|
|
|
91
136
|
const seen = new Set();
|
|
92
137
|
for (let i = 1; i < parts.length; i += 2) {
|
|
93
138
|
const sha = (parts[i] || '').slice(0, 12);
|
|
94
|
-
for (const f of scanHistoryDiff(parts[i + 1] || '', sha, detectFn)) {
|
|
139
|
+
for (const f of scanHistoryDiff(parts[i + 1] || '', sha, detectFn, extraDetectors || defaultExtra)) {
|
|
95
140
|
const key = `${f.vuln}:${(f.snippet || f.match || '').slice(0, 40)}`;
|
|
96
141
|
if (seen.has(key)) continue;
|
|
97
142
|
seen.add(key);
|
package/dist/1905.index.js
CHANGED
|
@@ -13,6 +13,8 @@ __webpack_require__.d(__webpack_exports__, {
|
|
|
13
13
|
|
|
14
14
|
// UNUSED EXPORTS: makeBudget, makeTaintProbe
|
|
15
15
|
|
|
16
|
+
// EXTERNAL MODULE: ./src/language/discovery.js
|
|
17
|
+
var discovery = __webpack_require__(30951);
|
|
16
18
|
// EXTERNAL MODULE: external "node:crypto"
|
|
17
19
|
var external_node_crypto_ = __webpack_require__(77598);
|
|
18
20
|
;// CONCATENATED MODULE: ./src/discovery/partition.js
|
|
@@ -32,6 +34,7 @@ var external_node_crypto_ = __webpack_require__(77598);
|
|
|
32
34
|
// comes straight back.
|
|
33
35
|
|
|
34
36
|
|
|
37
|
+
|
|
35
38
|
function focusAreaId(files) {
|
|
36
39
|
const canon = [...new Set(files || [])].sort().join('\n');
|
|
37
40
|
return external_node_crypto_.createHash('sha256').update(canon).digest('hex').slice(0, 12);
|
|
@@ -109,6 +112,33 @@ function partitionCallGraph(callGraph, opts = {}) {
|
|
|
109
112
|
return areas;
|
|
110
113
|
}
|
|
111
114
|
|
|
115
|
+
/**
|
|
116
|
+
* Focus areas for Nix configuration, which has no call graph: files joined by `imports` / path references form one
|
|
117
|
+
* area, so a NixOS entry point and the modules that decide its options are hunted together. Same shape as a call-graph
|
|
118
|
+
* area (`functions` is empty: a Nix file has none).
|
|
119
|
+
*/
|
|
120
|
+
function partitionNixFiles(fileContents, opts = {}) {
|
|
121
|
+
const maxAreas = Number.isInteger(opts.maxAreas) && opts.maxAreas > 0 ? opts.maxAreas : 8;
|
|
122
|
+
const nix = {};
|
|
123
|
+
for (const [f, t] of Object.entries(fileContents || {})) if (/\.nix$/i.test(f) && typeof t === 'string') nix[f] = t;
|
|
124
|
+
const files = Object.keys(nix).sort();
|
|
125
|
+
if (!files.length) return [];
|
|
126
|
+
const { graph } = (0,discovery/* buildImportGraph */.oK)(nix);
|
|
127
|
+
const dsu = makeDSU();
|
|
128
|
+
for (const f of files) dsu.find(f);
|
|
129
|
+
for (const [f, deps] of graph) for (const d of deps) dsu.union(f, d);
|
|
130
|
+
const groups = new Map();
|
|
131
|
+
for (const f of files) { const r = dsu.find(f); if (!groups.has(r)) groups.set(r, []); groups.get(r).push(f); }
|
|
132
|
+
let areas = [...groups.values()].map((g) => { const sorted = [...g].sort(); return { id: focusAreaId(sorted), label: labelFor(sorted), files: sorted, functions: [], size: sorted.length, language: 'nix' }; });
|
|
133
|
+
areas.sort((a, b) => b.size - a.size || (a.id < b.id ? -1 : 1));
|
|
134
|
+
if (areas.length > maxAreas) {
|
|
135
|
+
const kept = areas.slice(0, maxAreas - 1); const tail = areas.slice(maxAreas - 1).flatMap((a) => a.files).sort();
|
|
136
|
+
kept.push({ id: focusAreaId(tail), label: 'misc', files: tail, functions: [], size: tail.length, language: 'nix' });
|
|
137
|
+
areas = kept;
|
|
138
|
+
}
|
|
139
|
+
return areas;
|
|
140
|
+
}
|
|
141
|
+
|
|
112
142
|
// EXTERNAL MODULE: ./src/discovery/lenses.js
|
|
113
143
|
var discovery_lenses = __webpack_require__(33499);
|
|
114
144
|
// EXTERNAL MODULE: ./src/egress/policy.js
|
|
@@ -1057,7 +1087,16 @@ function makeBudget(opts = {}, now = Date.now) {
|
|
|
1057
1087
|
}
|
|
1058
1088
|
|
|
1059
1089
|
async function runDiscovery(ctx = {}, opts = {}) {
|
|
1060
|
-
|
|
1090
|
+
// Call-graph areas (every language the IR covers, Haskell included) plus Nix configuration areas, which have no call
|
|
1091
|
+
// graph. Together they respect the same ceiling.
|
|
1092
|
+
const maxAreas = opts.maxAreas ?? 8;
|
|
1093
|
+
let areas = [...partitionCallGraph(ctx.callGraph, { maxAreas }), ...partitionNixFiles(ctx.fileContents, { maxAreas })];
|
|
1094
|
+
if (areas.length > maxAreas) {
|
|
1095
|
+
const kept = areas.slice(0, maxAreas - 1); const tail = areas.slice(maxAreas - 1);
|
|
1096
|
+
const files = [...new Set(tail.flatMap((a) => a.files))].sort();
|
|
1097
|
+
kept.push({ id: tail.map((a) => a.id).join('+').slice(0, 64), label: 'misc', files, functions: tail.flatMap((a) => a.functions), size: tail.reduce((n, a) => n + a.size, 0) });
|
|
1098
|
+
areas = kept;
|
|
1099
|
+
}
|
|
1061
1100
|
|
|
1062
1101
|
const reasons = [];
|
|
1063
1102
|
const budget = makeBudget(opts);
|
|
@@ -1095,7 +1134,7 @@ async function runDiscovery(ctx = {}, opts = {}) {
|
|
|
1095
1134
|
for (const area of areas) {
|
|
1096
1135
|
let areaDegradedCount = 0;
|
|
1097
1136
|
for (const lens of lenses) {
|
|
1098
|
-
const run = await runHunter(area, lens, { fileContents: ctx.fileContents || {} }, { llmInvoke, scanRoot: opts.scanRoot });
|
|
1137
|
+
const run = await runHunter(area, lens, { fileContents: ctx.fileContents || {}, scanRoot: opts.scanRoot }, { llmInvoke, scanRoot: opts.scanRoot });
|
|
1099
1138
|
runs.push({ focusAreaId: run.focusAreaId, lens: run.lens, degraded: run.degraded, reason: run.reason, candidateCount: run.candidates.length });
|
|
1100
1139
|
if (run.degraded && run.reason) reasons.push(`${area.label} × ${lens.key}: ${run.reason}`);
|
|
1101
1140
|
if (run.degraded) areaDegradedCount += 1;
|
|
@@ -1267,6 +1306,7 @@ async function runDiscovery(ctx = {}, opts = {}) {
|
|
|
1267
1306
|
/* harmony export */ LENSES: () => (/* binding */ LENSES),
|
|
1268
1307
|
/* harmony export */ j: () => (/* binding */ buildHunterPrompt)
|
|
1269
1308
|
/* harmony export */ });
|
|
1309
|
+
/* harmony import */ var _egress_redact_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(74831);
|
|
1270
1310
|
//
|
|
1271
1311
|
// The seven hunting lenses. Each hunter run is one (focus area × lens) pair.
|
|
1272
1312
|
//
|
|
@@ -1276,6 +1316,7 @@ async function runDiscovery(ctx = {}, opts = {}) {
|
|
|
1276
1316
|
// same code than one told to look at crypto, so the union covers failure modes
|
|
1277
1317
|
// no single prompt reaches. `wildcard` exists because a fixed taxonomy is a
|
|
1278
1318
|
// ceiling, and the classes worth finding are the ones not on the list.
|
|
1319
|
+
|
|
1279
1320
|
const LENSES = Object.freeze([
|
|
1280
1321
|
{ key: 'injection', title: 'Injection', family: 'injection', cwe: 'CWE-74',
|
|
1281
1322
|
brief: 'Untrusted input reaching an interpreter: SQL, shell, template, XPath, LDAP, or deserialization. Follow the value, not the function name.' },
|
|
@@ -1308,7 +1349,9 @@ function buildHunterPrompt(focusArea, lens, ctx = {}) {
|
|
|
1308
1349
|
let budget = maxChars;
|
|
1309
1350
|
const blocks = [];
|
|
1310
1351
|
for (const f of files) {
|
|
1311
|
-
|
|
1352
|
+
// Everything that leaves the machine is redacted first (secrets, PII/PHI/PCI-shaped fields, operator-declared
|
|
1353
|
+
// customer data, and whole proprietary paths), by the same choke point the validator and the proposers use.
|
|
1354
|
+
const src = (0,_egress_redact_js__WEBPACK_IMPORTED_MODULE_0__/* .redactPayload */ .cy)({ text: contents[f], filePath: f, scanRoot: ctx.scanRoot || null }).text;
|
|
1312
1355
|
const slice = src.length > budget ? src.slice(0, Math.max(0, budget)) : src;
|
|
1313
1356
|
const truncated = slice.length < src.length;
|
|
1314
1357
|
blocks.push(`--- SOURCE FILE (untrusted data): ${f}${truncated ? ' (truncated)' : ''} ---\n${slice}\n--- END SOURCE FILE: ${f} ---`);
|