diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 8900f6e..9c3baea 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,12 +1,2 @@ -# The Sponsor button shows only for platforms you actually enroll in. -# Uncomment the line(s) you set up and delete the rest. - -# GitHub Sponsors — requires enrolling at https://github.com/sponsors (Stripe/bank setup) -# github: [warpedatom] - -# One-off / recurring tip jars — fastest to set up, no approval needed: -# ko_fi: yourname # https://ko-fi.com/yourname -# buy_me_a_coffee: yourname # https://buymeacoffee.com/yourname - -# Any other URL (Liberapay, PayPal.me, etc.) -# custom: ["https://..."] +# Sponsor button for this repository (GitHub Sponsors). +github: warpedatom diff --git a/CHANGELOG.md b/CHANGELOG.md index 98c9b5f..a0f4771 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,12 +2,27 @@ All notable changes to OffsetInspect are documented in this file. The project follows semantic versioning. -## [Unreleased] +## [3.3.0] - 2026-07-27 -### Planned +### Added -- Additional provider adapters after the v2 provider contract has received field testing. -- Published benchmark baselines for representative text and binary corpora. +- **`Get-OffsetSignature` - Authenticode signature verification (Windows-native).** Reports a + file's Authenticode signature using the platform's real trust validation + (`Get-AuthenticodeSignature` / WinVerifyTrust) - not just "is a certificate present" but is the + signature **Valid and trusted** against this machine's trust store, who signed it + (subject/issuer/thumbprint/serial/validity), whether it is **embedded- or catalog-signed**, and + whether it is a Windows **OS binary**. Returns the `OffsetInspect.SignatureInfo` object + (`IsSigned`, `Status`, `SignatureType`, `IsCatalogSigned`, `IsOSBinary`, `SignerName`/`Subject`/ + `Issuer`/`Thumbprint`/`Serial`, `SignerNotBefore`/`NotAfter`, `SignerExpired`, `IsTimestamped`, + `TimestamperName`). A provenance signal that complements imphash and the Rich-header fingerprint: + *imports vs build toolchain vs signer*. An unsigned binary wearing a system-DLL name, or a + present-but-not-`Valid` signature, is a triage finding. + - Windows-only: Authenticode trust validation needs the Windows trust store, so on other + platforms it throws (mirroring the AMSI/Defender providers). The object-shaping is a pure, + fixture-unit-tested helper (`ConvertTo-OISignatureInfo`) that runs cross-platform in CI, plus + a Windows-only end-to-end test against a real catalog-signed system binary. + - Ships alongside the OffsetScan 0.4.0 Rich-header feature as a joint "provenance & + attribution" update. ## [3.2.0] - 2026-07-21 @@ -21,12 +36,12 @@ are unchanged. was an alert raised, with what context, and which telemetry sources were blind. It captures each accessible log's high-water `RecordId` immediately before the scan, then after it reports the detection(s) attributable to the scan. Encodes the "assume visibility, then validate it" - principle — a scan with no alert, an alert lacking a threat name, or a missing source are each + principle - a scan with no alert, an alert lacking a threat name, or a missing source are each emitted as `Findings`. Adds the `OffsetInspect.TelemetryCorrelation` object as a `Telemetry` property on `ThreatScanResult` (`AlertGenerated`, `Alert`, `DefenderEvents`, `CorrelationConfidence`, `SourcesAccessible`, `SourcesUnavailable`, `Findings`). - Primary source is the Microsoft Defender Operational log (event 1116/1117), which is - readable **non-admin**. Correlation is by `RecordId` (monotonic, timezone-proof — the + readable **non-admin**. Correlation is by `RecordId` (monotonic, timezone-proof - the Get-WinEvent `StartTime` filter is local-time and would silently exclude events). - Confidence is **High** only when a detection's `Source Name` matches the provider (AMSI) **and** its process matches the scanning host, so a coincidental concurrent detection is @@ -45,7 +60,7 @@ Bug-fix release. No command, parameter, or output-schema changes. - **imphash now resolves ordinal imports from `ws2_32`/`wsock32`/`oleaut32` to their real function names, matching pefile and VirusTotal.** `Get-OffsetPEInfo`/`Get-OffsetIOC` previously rendered every ordinal import as `ord`, so a binary importing those - libraries by ordinal — a common malware networking pattern — produced an imphash that + libraries by ordinal - a common malware networking pattern - produced an imphash that did not match the value analysts look up on VirusTotal (which uses pefile). The imphash was correct but non-correlatable for those samples, the opposite of its purpose. Ordinals from those three libraries are now resolved via pefile's tables; all other ordinal imports @@ -71,7 +86,7 @@ Bug-fix release. No command, parameter, or output-schema changes. `Int32` value `-2147483648`, and casting a negative number to `UInt64` throws. Import parsing therefore aborted on its first statement for all PE32 files; the exception was caught and recorded only in the `Warnings` collection, which `Get-OffsetIOC` does not - surface — so the failure was invisible. The PE32+ (x64) branch already used a hex-string + surface - so the failure was invisible. The PE32+ (x64) branch already used a hex-string conversion to sidestep the same overflow class; the PE32 branch now does too. Verified against `SysWOW64\kernel32.dll` (imphash `5c665927b146db2f5155688ad978e69f`, 102 imports) and a 32-bit .NET assembly, both matching the native OffsetScan engine field-for-field. @@ -81,7 +96,7 @@ Bug-fix release. No command, parameter, or output-schema changes. - imphash is a primary indicator for malware clustering, and a large fraction of malware ships as 32-bit PE32. Prior versions silently produced no imphash for those samples, so a triage row or report could omit the single most useful correlation key with no error - shown. Any 32-bit sample analyzed with 3.0.0–3.1.1 should be re-run. + shown. Any 32-bit sample analyzed with 3.0.0-3.1.1 should be re-run. ### Added @@ -98,7 +113,7 @@ Bug-fix release. No command, parameter, or output-schema changes. - **`Get-OffsetString` split a string straddling a read-window seam into two truncated halves.** The file is scanned in bounded-memory windows (1 MiB by default); a run that reached the end of a window was emitted as-is and the remainder reported separately as a - new string. An indicator spanning a seam — a URL, C2 domain, or mutex name — was therefore + new string. An indicator spanning a seam - a URL, C2 domain, or mutex name - was therefore reported as two fragments, and a filter such as `Where-Object Value -match 'http'` could miss it entirely. A trailing run is now held back and scanned with the following window, so a straddling string is reported once, whole. The one remaining exception is a string @@ -115,7 +130,7 @@ Bug-fix release. No command, parameter, or output-schema changes. - Cross-engine parity with the native [OffsetScan](https://github.com/warpedatom/OffsetScan) engine is now exact for string extraction. `Get-OffsetString` and `offsetscan strings` - return set-identical results (offset, encoding, and value) on `ntdll.dll` — 32,506 hits, + return set-identical results (offset, encoding, and value) on `ntdll.dll` - 32,506 hits, zero entries unique to either engine, holding even at a 4 KiB window (~600 seams). OffsetScan 0.1.1 fixes the corresponding defect on its side. @@ -132,7 +147,7 @@ meanings are unchanged. ### Added -- `Get-OffsetDetectionTrigger` — correlates a detection boundary to the content that most +- `Get-OffsetDetectionTrigger` - correlates a detection boundary to the content that most likely produced it. Because a prefix boundary is the last byte of the earliest detected prefix, the trigger is a run ending at that offset; the command reports the PE section the boundary falls in, the entropy of the run up to it (plaintext vs packed/encoded), and the @@ -147,12 +162,12 @@ meanings are unchanged. changed), a **signature-database update** (Defender signature version changed with the file unchanged), or a **non-deterministic** provider result. New output objects `OffsetInspect.DriftEntry` and `OffsetInspect.DriftReport`. -- `Export-OffsetThreatReport -IocJsonPath` — sources IOC panels from the native OffsetScan +- `Export-OffsetThreatReport -IocJsonPath` - sources IOC panels from the native OffsetScan engine's JSON (`offsetscan ioc > ioc.json`) instead of re-scanning each file in PowerShell; `-IncludeIoc` becomes the live fallback for files absent from the JSON. -- `Export-OffsetThreatReport -IncludeTrigger` — embeds detection-trigger analysis in the +- `Export-OffsetThreatReport -IncludeTrigger` - embeds detection-trigger analysis in the Markdown and HTML reports. -- `Invoke-OffsetMutationTest` — for authorized engagements, tests how robust a signature is by +- `Invoke-OffsetMutationTest` - for authorized engagements, tests how robust a signature is by applying standard perturbations (case inversion, string-literal concatenation, comment insertion, whitespace injection) to a detected sample and re-scanning each variant with AMSI to report which transform classes neutralize detection. Everything runs in memory; no variant diff --git a/Code_of_Conduct.md b/Code_of_Conduct.md index bf2e344..ca50651 100644 --- a/Code_of_Conduct.md +++ b/Code_of_Conduct.md @@ -9,7 +9,7 @@ By participating in this project (issues, pull requests, discussions, reviews, o ## Our Standards - Be respectful and professional in all interactions. -- Focus feedback on code, ideas, and behavior — never on the person. +- Focus feedback on code, ideas, and behavior - never on the person. - Assume good intent and ask clarifying questions instead of reacting with hostility. - Welcome newcomers and help them understand the project where possible. - Use clear, inclusive language and avoid insults, slurs, or personal attacks. diff --git a/README.md b/README.md index 0944e14..775a3c4 100644 --- a/README.md +++ b/README.md @@ -21,13 +21,13 @@ OffsetInspect answers a practical analyst question: > What content is present at this byte offset, and what source or binary context surrounds it? -It also provides an OffsetInspect-native detection-boundary workflow inspired by the same analyst problem addressed by ThreatCheck, without bundling its source or binaries: it locates the earliest content prefix that AMSI or Microsoft Defender still detects, validates the boundary repeatedly, and feeds the resulting offset straight into the context inspector. On top of that core, it adds a red-team analysis and static-triage suite — multi-region discovery, corpus scanning, detection diffing, detection-trigger correlation, drift journaling, engagement reports, entropy analysis, string extraction, and PE/imphash parsing — all read-only, plus an authorized-use signature-robustness tester that perturbs samples only in memory, and without ever disabling or reconfiguring endpoint protection. +It also provides an OffsetInspect-native detection-boundary workflow inspired by the same analyst problem addressed by ThreatCheck, without bundling its source or binaries: it locates the earliest content prefix that AMSI or Microsoft Defender still detects, validates the boundary repeatedly, and feeds the resulting offset straight into the context inspector. On top of that core, it adds a red-team analysis and static-triage suite - multi-region discovery, corpus scanning, detection diffing, detection-trigger correlation, drift journaling, engagement reports, entropy analysis, string extraction, and PE/imphash parsing - all read-only, plus an authorized-use signature-robustness tester that perturbs samples only in memory, and without ever disabling or reconfiguring endpoint protection. ## Companion tool For corpus-scale static triage (PE parsing, entropy, strings, IOC) without PowerShell overhead, see **[OffsetScan](https://github.com/warpedatom/OffsetScan)** -— a native Rust binary with the same JSON output schema. OffsetInspect 3.1.0+ +- a native Rust binary with the same JSON output schema. OffsetInspect 3.1.0+ ingests OffsetScan IOC JSON directly via `-IocJsonPath`. --- @@ -46,11 +46,12 @@ ingests OffsetScan IOC JSON directly via `-IocJsonPath`. - Records a per-probe audit trail (`ProbeLog`/`ProbeCount`) of every distinct provider invocation, streamed live to `-Verbose`, for a report-ready transcript of a scan's true provider cost. - Discovers multiple independently-detectable regions in one file via in-memory AMSI scanning (nothing detected is written to disk) and maps each boundary to an absolute offset. - Scans a corpus into a consolidated detection matrix, diffs detection between two scans, and exports Markdown/HTML engagement reports (optionally fed by the native OffsetScan engine's JSON for corpus-scale IOC panels). -- Correlates a detection boundary to the content that produced it — the PE section, the entropy of the run up to the boundary, and the strings ending at/straddling it as candidate signature content. +- Correlates a detection boundary to the content that produced it - the PE section, the entropy of the run up to the boundary, and the strings ending at/straddling it as candidate signature content. - Journals detection over time (file hash **and** the local Defender signature version) so a change in detectability can be attributed to the file, to a signature-database update, or to a non-deterministic provider result. -- Tests signature robustness for authorized engagements by perturbing a detected sample **in memory** (case, concatenation, comment, whitespace) and reporting which transform classes evade — no variant is ever written to disk. -- Correlates a scan with the Windows telemetry it generates (`-CaptureTelemetry`): whether a Microsoft Defender alert was raised, with what context, and which telemetry sources were blind — encoding the "assume visibility, then validate it" principle. Read-only, non-admin, Windows-only. +- Tests signature robustness for authorized engagements by perturbing a detected sample **in memory** (case, concatenation, comment, whitespace) and reporting which transform classes evade - no variant is ever written to disk. +- Correlates a scan with the Windows telemetry it generates (`-CaptureTelemetry`): whether a Microsoft Defender alert was raised, with what context, and which telemetry sources were blind - encoding the "assume visibility, then validate it" principle. Read-only, non-admin, Windows-only. - Adds static malware-triage helpers: per-window entropy (packed/encrypted regions), ASCII/UTF-16LE string extraction with offsets, and PE header/section/import parsing with imphash and overlay detection. +- Verifies Authenticode provenance (`Get-OffsetSignature`): using the platform's real trust validation, reports whether a file is validly signed and trusted, who signed it, and whether it is embedded- or catalog-signed - a signer signal that complements imphash and the build-toolchain fingerprint (imports vs toolchain vs signer). Windows-only. - Never changes Defender exclusions, real-time protection, or system security configuration. - Ships as a self-contained PowerShell Gallery package with no external runtime dependencies; YARA and ClamAV scanning are the only optional exceptions, each requiring its own external engine. @@ -74,8 +75,9 @@ ingests OffsetScan IOC JSON directly via `-IocJsonPath`. | `Get-OffsetString` | Extract ASCII/UTF-16LE strings with byte offsets | Cross-platform | | `Get-OffsetPEInfo` | PE headers, sections, imports/imphash, overlay, offset→section | Cross-platform | | `Get-OffsetIOC` | Consolidated indicator panel: hashes, entropy, PE/imphash, strings | Cross-platform | +| `Get-OffsetSignature` | Authenticode signature: is it validly signed and trusted, by whom, embedded vs catalog | Windows | -¹ These two commands have optional external dependencies: `Invoke-OffsetYaraScan` needs the YARA engine (`winget install VirusTotal.YARA`), and `Invoke-OffsetClamScan` needs ClamAV with signature databases (`winget install Cisco.ClamAV`, then `freshclam`). Every other command is self-contained. ClamAV is a single-file detector here, not a boundary-search engine — `clamscan` loads its full database per invocation, so bisection would require the `clamd` daemon. +¹ These two commands have optional external dependencies: `Invoke-OffsetYaraScan` needs the YARA engine (`winget install VirusTotal.YARA`), and `Invoke-OffsetClamScan` needs ClamAV with signature databases (`winget install Cisco.ClamAV`, then `freshclam`). Every other command is self-contained. ClamAV is a single-file detector here, not a boundary-search engine - `clamscan` loads its full database per invocation, so bisection would require the `clamd` daemon. ² `Add-OffsetDriftEntry` journals cross-platform, but the Defender signature/engine version fields populate only on Windows (via `Get-MpComputerStatus`); elsewhere they record as null and the rest of the snapshot is still written. @@ -295,15 +297,15 @@ Target byte: 69 (105) 0006CDFF 69 74 0A it. ``` -Both engines converge on **line 4586** — Defender's boundary falls inside the trailing `it` of `Invoke-PrivescAudit`, AMSI's on the newline that terminates the same line, two bytes later. Neither offset is "the signature": they are the earliest prefix each provider still flagged, and the two-byte disagreement is exactly the tokenization/context effect described above. Defender additionally names what it matched (`Trojan:Win32/Kepavll!rfn`); AMSI reports no signature name, which is why `Invoke-OffsetThreatScanRegion` and `Get-OffsetDetectionTrigger` exist to characterize an AMSI hit. +Both engines converge on **line 4586** - Defender's boundary falls inside the trailing `it` of `Invoke-PrivescAudit`, AMSI's on the newline that terminates the same line, two bytes later. Neither offset is "the signature": they are the earliest prefix each provider still flagged, and the two-byte disagreement is exactly the tokenization/context effect described above. Defender additionally names what it matched (`Trojan:Win32/Kepavll!rfn`); AMSI reports no signature name, which is why `Invoke-OffsetThreatScanRegion` and `Get-OffsetDetectionTrigger` exist to characterize an AMSI hit. -Both scans cost 25 provider probes for a ~436 KiB file — the bisection is logarithmic in file size, and every probe is recorded in `ProbeLog`. +Both scans cost 25 provider probes for a ~436 KiB file - the bisection is logarithmic in file size, and every probe is recorded in `ProbeLog`. See [Threat scanning design](./docs/THREAT-SCANNING.md) for the provider contract and interpretation guidance, [provider interface](./docs/PROVIDER-INTERFACE.md) for the scanner contract and how to add a provider without touching the search core, [threat-scanning provenance](./docs/PROVENANCE.md) for implementation boundaries and attribution, and [output schema](./docs/OUTPUT-SCHEMA.md) for the versioned object contract. ### Detection-boundary reports -`Export-OffsetThreatReport` turns one or more scan results into a self-contained Markdown or HTML report — per-file summary, provider/signature/engine metadata, the full `ProbeLog` audit trail, and warnings — for attaching to an engagement writeup. It reads results only and never re-scans, so it runs cross-platform. Add `-IncludeIoc` to fold a hash/entropy/PE indicator panel (the same data as `Get-OffsetIOC`) into each report entry, and `-IncludeTrigger` to add a detection-trigger analysis (see below) for every result with a boundary. +`Export-OffsetThreatReport` turns one or more scan results into a self-contained Markdown or HTML report - per-file summary, provider/signature/engine metadata, the full `ProbeLog` audit trail, and warnings - for attaching to an engagement writeup. It reads results only and never re-scans, so it runs cross-platform. Add `-IncludeIoc` to fold a hash/entropy/PE indicator panel (the same data as `Get-OffsetIOC`) into each report entry, and `-IncludeTrigger` to add a detection-trigger analysis (see below) for every result with a boundary. ```powershell Invoke-OffsetThreatScan ./sample.ps1 -Engine AMSI -ScanMode Text -PassThru | @@ -334,7 +336,7 @@ Invoke-OffsetThreatScanBatch ./samples -Summary | ### Detection diff / regression -`Compare-OffsetThreatResult` diffs two scan results — for example the same file before and after a signature-definition update — and classifies the change (`NewlyDetected`, `NoLongerDetected`, `BoundaryEarlier`, `BoundaryLater`, `BoundaryUnchanged`, `BothClean`) with the boundary delta and changed fields. +`Compare-OffsetThreatResult` diffs two scan results - for example the same file before and after a signature-definition update - and classifies the change (`NewlyDetected`, `NoLongerDetected`, `BoundaryEarlier`, `BoundaryLater`, `BoundaryUnchanged`, `BothClean`) with the boundary delta and changed fields. ```powershell $before = Invoke-OffsetThreatScan ./sample.ps1 -Engine Defender -PassThru @@ -345,7 +347,7 @@ Compare-OffsetThreatResult -Reference $before -Difference $after ### Multi-region discovery -The prefix search finds the *first* detection boundary. `Invoke-OffsetThreatScanRegion` finds *multiple* independently-detectable regions by splitting the file into segments and scanning each in isolation through AMSI **entirely in memory** — nothing detected is written to disk, so Defender real-time protection is never triggered or reconfigured. Each hit is bisected within its segment to map the exact triggering boundary to an absolute file offset. +The prefix search finds the *first* detection boundary. `Invoke-OffsetThreatScanRegion` finds *multiple* independently-detectable regions by splitting the file into segments and scanning each in isolation through AMSI **entirely in memory** - nothing detected is written to disk, so Defender real-time protection is never triggered or reconfigured. Each hit is bisected within its segment to map the exact triggering boundary to an absolute file offset. ```powershell Invoke-OffsetThreatScanRegion ./payload.bin -SegmentCount 16 | @@ -353,11 +355,11 @@ Invoke-OffsetThreatScanRegion ./payload.bin -SegmentCount 16 | Format-Table SegmentIndex, StartOffset, EndOffset, AbsoluteBoundaryOffset, SignatureName ``` -This reports regions that trigger on their own; it can miss signatures that only fire in full-file context or that straddle a segment boundary, so treat the regions as leads to confirm with `Invoke-OffsetThreatScan` and manual validation. AMSI (in-memory) is the only engine supported here — Defender file scanning would require writing detected content to disk. +This reports regions that trigger on their own; it can miss signatures that only fire in full-file context or that straddle a segment boundary, so treat the regions as leads to confirm with `Invoke-OffsetThreatScan` and manual validation. AMSI (in-memory) is the only engine supported here - Defender file scanning would require writing detected content to disk. ### Detection-trigger correlation -A boundary tells you *where* detection flips; `Get-OffsetDetectionTrigger` tells you *what* is there. Because a prefix boundary is the last byte of the earliest detected prefix, the triggering content is a run ending at that offset. The command reports the PE section the boundary falls in, the entropy of the run up to it (plaintext vs packed/encoded), and the extracted strings ending at or straddling it, ranked by proximity — the candidate signature content — with a one-line interpretation. It reads bytes only and never re-scans, so it runs cross-platform on saved results. +A boundary tells you *where* detection flips; `Get-OffsetDetectionTrigger` tells you *what* is there. Because a prefix boundary is the last byte of the earliest detected prefix, the triggering content is a run ending at that offset. The command reports the PE section the boundary falls in, the entropy of the run up to it (plaintext vs packed/encoded), and the extracted strings ending at or straddling it, ranked by proximity - the candidate signature content - with a one-line interpretation. It reads bytes only and never re-scans, so it runs cross-platform on saved results. ```powershell Invoke-OffsetThreatScan ./flagged.ps1 -Engine AMSI -PassThru | Get-OffsetDetectionTrigger @@ -369,7 +371,7 @@ Get-OffsetDetectionTrigger -FilePath ./sample.bin -BoundaryOffset 0x4A1 | ### Detection-drift journal -"It was detected before and now it isn't" has three very different causes: the file changed, the signatures changed, or the provider is non-deterministic. `Add-OffsetDriftEntry` records append-only NDJSON snapshots — file SHA-256, status, boundary, signature name, and the local Defender signature/engine versions — and `Get-OffsetDrift` reads that history and attributes each change to the right cause. +"It was detected before and now it isn't" has three very different causes: the file changed, the signatures changed, or the provider is non-deterministic. `Add-OffsetDriftEntry` records append-only NDJSON snapshots - file SHA-256, status, boundary, signature name, and the local Defender signature/engine versions - and `Get-OffsetDrift` reads that history and attributes each change to the right cause. ```powershell # Record a snapshot over time (from a scan result, or directly): @@ -384,7 +386,7 @@ Each transition is labelled: a SHA-256 change reads as a **file modification**; ### Signature-robustness testing (authorized use only) -`Invoke-OffsetMutationTest` answers a detection-engineering question: is a signature a brittle exact-literal match, or is it robust to common obfuscation? Given a sample that AMSI currently detects, it applies standard perturbations — case inversion, string-literal concatenation, comment insertion, whitespace injection — and re-scans each variant to report which classes neutralize detection. **Everything happens in memory** via AMSI's in-process interface; no variant is written to disk, so no evasive artifacts are produced and Defender real-time protection is not involved. The command refuses to run without `-AuthorizedEngagement`, and is intended only for samples you are authorized to test. +`Invoke-OffsetMutationTest` answers a detection-engineering question: is a signature a brittle exact-literal match, or is it robust to common obfuscation? Given a sample that AMSI currently detects, it applies standard perturbations - case inversion, string-literal concatenation, comment insertion, whitespace injection - and re-scans each variant to report which classes neutralize detection. **Everything happens in memory** via AMSI's in-process interface; no variant is written to disk, so no evasive artifacts are produced and Defender real-time protection is not involved. The command refuses to run without `-AuthorizedEngagement`, and is intended only for samples you are authorized to test. ```powershell Invoke-OffsetMutationTest -FilePath ./flagged.ps1 -AuthorizedEngagement | @@ -395,7 +397,7 @@ A result of, say, "brittle: neutralized by StringConcatenation, CommentInsertion ### Telemetry correlation -Detecting a boundary tells you what the *engine* sees; `-CaptureTelemetry` tells you what the *defender* sees. It snapshots each accessible Windows telemetry log's high-water mark before the scan, then reports whether the action raised an alert, with what context, and which sources were blind — the "assume visibility, then validate it" question, answered with evidence. +Detecting a boundary tells you what the *engine* sees; `-CaptureTelemetry` tells you what the *defender* sees. It snapshots each accessible Windows telemetry log's high-water mark before the scan, then reports whether the action raised an alert, with what context, and which sources were blind - the "assume visibility, then validate it" question, answered with evidence. ```powershell $r = Invoke-OffsetThreatScan ./flagged.ps1 -Engine AMSI -CaptureTelemetry -PassThru @@ -405,10 +407,10 @@ $r.Telemetry.Alert | Format-List ThreatName, SeverityName, SourceName, ProcessNa The `Telemetry` property (`OffsetInspect.TelemetryCorrelation`) reports: -- `AlertGenerated` / `Alert` — whether a Microsoft Defender detection (event 1116/1117) was logged for the scan, and its context (threat name, severity, detection source, process, user). -- `CorrelationConfidence` — **High** only when the detection's source matches the provider **and** its process matches the scanning host, so a coincidental concurrent detection is never claimed; Medium on source alone; Low on neither. -- `SourcesAccessible` / `SourcesUnavailable` — which telemetry logs were readable and which were blind (Sysmon absent, the Security log requiring elevation). A visibility gap is itself a finding. -- `Findings` — plain-language conclusions: an alert with full context, an alert lacking a threat name, no telemetry at all, or a missing source. +- `AlertGenerated` / `Alert` - whether a Microsoft Defender detection (event 1116/1117) was logged for the scan, and its context (threat name, severity, detection source, process, user). +- `CorrelationConfidence` - **High** only when the detection's source matches the provider **and** its process matches the scanning host, so a coincidental concurrent detection is never claimed; Medium on source alone; Low on neither. +- `SourcesAccessible` / `SourcesUnavailable` - which telemetry logs were readable and which were blind (Sysmon absent, the Security log requiring elevation). A visibility gap is itself a finding. +- `Findings` - plain-language conclusions: an alert with full context, an alert lacking a threat name, no telemetry at all, or a missing source. The primary source is the Microsoft Defender Operational log, readable without elevation; correlation is by event `RecordId` (monotonic and timezone-independent). Windows-only, and inert unless `-CaptureTelemetry` is passed. @@ -416,10 +418,10 @@ The primary source is the Microsoft Defender Operational log, readable without e Three cross-platform static-analysis commands support malware triage and compose with the offset core: -- `Get-OffsetEntropy` — per-window Shannon entropy (bits/byte) to locate packed or encrypted regions; cross-reference the flagged windows with `Invoke-OffsetThreatScanRegion` detections. -- `Get-OffsetString` — printable ASCII and UTF-16LE strings with byte offsets; pipe offsets into `Invoke-OffsetInspect` for context. -- `Get-OffsetPEInfo` — PE machine/bitness, entry point, section table, **imports and imphash**, appended-**overlay** detection, and resource size, with `-Offset` mapping a byte offset to its section (`.text`, `.rsrc`, ...). Imphash uses the standard `library.function` MD5 and is verified byte-identical to pefile/VirusTotal — including special-library ordinal resolution, so an ordinal imported from `ws2_32`/`wsock32`/`oleaut32` resolves to its real function name; every other ordinal import renders `ordNNN`, exactly as pefile does. -- `Get-OffsetIOC` — one-shot indicator panel combining the above: MD5/SHA-1/SHA-256 (single-pass), overall entropy, printable-string count, and PE machine/imphash/overlay when applicable. +- `Get-OffsetEntropy` - per-window Shannon entropy (bits/byte) to locate packed or encrypted regions; cross-reference the flagged windows with `Invoke-OffsetThreatScanRegion` detections. +- `Get-OffsetString` - printable ASCII and UTF-16LE strings with byte offsets; pipe offsets into `Invoke-OffsetInspect` for context. +- `Get-OffsetPEInfo` - PE machine/bitness, entry point, section table, **imports and imphash**, appended-**overlay** detection, and resource size, with `-Offset` mapping a byte offset to its section (`.text`, `.rsrc`, ...). Imphash uses the standard `library.function` MD5 and is verified byte-identical to pefile/VirusTotal - including special-library ordinal resolution, so an ordinal imported from `ws2_32`/`wsock32`/`oleaut32` resolves to its real function name; every other ordinal import renders `ordNNN`, exactly as pefile does. +- `Get-OffsetIOC` - one-shot indicator panel combining the above: MD5/SHA-1/SHA-256 (single-pass), overall entropy, printable-string count, and PE machine/imphash/overlay when applicable. ```powershell Get-OffsetEntropy ./sample.bin -HighOnly | Select-Object -ExpandProperty Windows @@ -430,7 +432,7 @@ Get-OffsetIOC ./sample.exe | Format-List ### YARA scanning -`Invoke-OffsetYaraScan` runs analyst-authored YARA rules and returns each match with its byte offset — complementing the AMSI/Defender detection-boundary view with signatures you control, and needing no antivirus installed (only the YARA engine, e.g. `winget install VirusTotal.YARA`). Offsets feed straight into the inspector. +`Invoke-OffsetYaraScan` runs analyst-authored YARA rules and returns each match with its byte offset - complementing the AMSI/Defender detection-boundary view with signatures you control, and needing no antivirus installed (only the YARA engine, e.g. `winget install VirusTotal.YARA`). Offsets feed straight into the inspector. ```powershell Invoke-OffsetYaraScan ./sample.bin -RulePath ./rules/malware.yar | @@ -439,7 +441,7 @@ Invoke-OffsetYaraScan ./sample.bin -RulePath ./rules/malware.yar | ### ClamAV scanning -`Invoke-OffsetClamScan` scans a file with the ClamAV on-demand engine and returns a normalized result (`Clean` / `Detected` / `Error`, plus the signature name). Because `clamscan` loads its full signature database on every call, it is a single-file detector, not a boundary-search engine (that would require the `clamd` daemon). It needs ClamAV installed **and** its signature databases downloaded — `freshclam` will not run until a config file exists: +`Invoke-OffsetClamScan` scans a file with the ClamAV on-demand engine and returns a normalized result (`Clean` / `Detected` / `Error`, plus the signature name). Because `clamscan` loads its full signature database on every call, it is a single-file detector, not a boundary-search engine (that would require the `clamd` daemon). It needs ClamAV installed **and** its signature databases downloaded - `freshclam` will not run until a config file exists: ```powershell # One-time setup: create the freshclam config (remove the sample's "Example" line), then fetch databases. diff --git a/build/Test-Module.ps1 b/build/Test-Module.ps1 index c42eeac..5dc5d3e 100644 --- a/build/Test-Module.ps1 +++ b/build/Test-Module.ps1 @@ -23,7 +23,7 @@ try { Import-Module (Join-Path $tempModule 'OffsetInspect.psd1') -Force -ErrorAction Stop $exports = @(Get-Command -Module OffsetInspect | Select-Object -ExpandProperty Name | Sort-Object) - $expected = @('Add-OffsetDriftEntry', 'Compare-OffsetThreatResult', 'Export-OffsetThreatReport', 'Get-OffsetDetectionTrigger', 'Get-OffsetDrift', 'Get-OffsetEntropy', 'Get-OffsetIOC', 'Get-OffsetPEInfo', 'Get-OffsetString', 'Invoke-OffsetClamScan', 'Invoke-OffsetInspect', 'Invoke-OffsetMutationTest', 'Invoke-OffsetThreatScan', 'Invoke-OffsetThreatScanBatch', 'Invoke-OffsetThreatScanRegion', 'Invoke-OffsetYaraScan') + $expected = @('Add-OffsetDriftEntry', 'Compare-OffsetThreatResult', 'Export-OffsetThreatReport', 'Get-OffsetDetectionTrigger', 'Get-OffsetDrift', 'Get-OffsetEntropy', 'Get-OffsetIOC', 'Get-OffsetPEInfo', 'Get-OffsetSignature', 'Get-OffsetString', 'Invoke-OffsetClamScan', 'Invoke-OffsetInspect', 'Invoke-OffsetMutationTest', 'Invoke-OffsetThreatScan', 'Invoke-OffsetThreatScanBatch', 'Invoke-OffsetThreatScanRegion', 'Invoke-OffsetYaraScan') if (($exports -join ',') -ne ($expected -join ',')) { throw "Unexpected exports: $($exports -join ', ')" } diff --git a/docs/OUTPUT-SCHEMA.md b/docs/OUTPUT-SCHEMA.md index 260a745..5d9be71 100644 --- a/docs/OUTPUT-SCHEMA.md +++ b/docs/OUTPUT-SCHEMA.md @@ -68,7 +68,7 @@ cross-platform (no scanner invocation). Added in 3.1.0. | `BoundaryByteDecimal` / `BoundaryByteHex` | Int32 / String or null | Value of the boundary byte | | `Section` | String or null | PE section containing the boundary (`headers`/name), null for non-PE | | `RegionStart` / `RegionEnd` / `RegionSize` | Int64 / Int64 / Int | Inclusive bounds of the analyzed window | -| `PreBoundaryEntropy` | Double | Shannon entropy (bits/byte) of the run up to the boundary — low suggests plaintext, high suggests packed/encoded | +| `PreBoundaryEntropy` | Double | Shannon entropy (bits/byte) of the run up to the boundary - low suggests plaintext, high suggests packed/encoded | | `CandidateStrings` | Object[] | Extracted strings ending at or straddling the boundary, ranked by proximity. Each has `Offset`, `OffsetHex`, `Encoding`, `Length`, `Value`, `EndsAtOffset`, `EndsAtHex`, `ContainsBoundary`, `DistanceToBoundary` | | `Interpretation` | String | One-line heuristic read of the likely trigger | | `HexDump` | Object[] | Structured hex rows for the window with the boundary byte highlighted | @@ -96,7 +96,7 @@ changes can later be attributed to the file or to the signatures. Added in 3.1.0 ## `OffsetInspect.DriftReport` -Produced by `Get-OffsetDrift` — one per file, summarizing its journal history and explaining +Produced by `Get-OffsetDrift` - one per file, summarizing its journal history and explaining each transition. Added in 3.1.0. | Property | Type | Meaning | @@ -131,7 +131,7 @@ No variant is written to disk. Added in 3.1.0. ## `OffsetInspect.TelemetryCorrelation` The `Telemetry` property of a `ThreatScanResult` when `Invoke-OffsetThreatScan -CaptureTelemetry` -is used ($null otherwise). Correlates the scan with the Windows telemetry it generated — whether +is used ($null otherwise). Correlates the scan with the Windows telemetry it generated - whether a Microsoft Defender alert was raised, with what context, and which sources were blind. Windows-only. Added in 3.2.0. @@ -147,6 +147,31 @@ Added in 3.2.0. | `Findings` | String[] | Plain-language conclusions (alert with/without context, no telemetry, visibility gaps) | | `PollDurationMs` | Double | Time spent polling for asynchronously-written Defender events | +## `OffsetInspect.SignatureInfo` + +Produced by `Get-OffsetSignature`. Reports a file's Authenticode signature using the platform's +real trust validation (`Get-AuthenticodeSignature` / WinVerifyTrust) - validity against the +machine's trust store, signer identity, and signing kind. Windows-only. Added in 3.3.0. + +| Property | Type | Meaning | +|---|---|---| +| `File` | String | File verified | +| `FileSize` | Int64 | Size in bytes | +| `IsSigned` | Boolean | Whether a signer certificate is present (true for embedded *and* catalog signatures, even when the signature does not validate) | +| `Status` | String | Verification status: `Valid`, `NotSigned`, `HashMismatch`, `NotTrusted`, `UnknownError`, `NotSupportedFileFormat` | +| `StatusMessage` | String or null | The provider's human-readable status message | +| `SignatureType` | String or null | `Authenticode` (embedded), `Catalog`, or `None`; null on hosts whose provider omits the field | +| `IsCatalogSigned` | Boolean | Whether the signature is catalog- rather than embedded | +| `IsOSBinary` | Boolean or null | Whether the OS identifies it as a Windows component; null when the provider omits the field | +| `SignerName` | String or null | Signer certificate common name (CN) | +| `SignerSubject` / `SignerIssuer` | String or null | Full signer subject / issuer distinguished names | +| `SignerThumbprint` / `SignerSerial` | String or null | Signer certificate thumbprint / serial number | +| `SignerNotBefore` / `SignerNotAfter` | DateTime or null | Signer certificate validity window | +| `SignerExpired` | Boolean or null | Whether the signer certificate is past `NotAfter` now (null when no signer) | +| `IsTimestamped` | Boolean | Whether a trusted timestamp countersignature is present | +| `TimestamperName` | String or null | Timestamping authority common name | +| `Warnings` | String[] | Non-fatal notes | + ## Serialization - `Invoke-OffsetInspect -Json` always emits an array. diff --git a/docs/PROVIDER-INTERFACE.md b/docs/PROVIDER-INTERFACE.md index 7b14c19..bf301c0 100644 --- a/docs/PROVIDER-INTERFACE.md +++ b/docs/PROVIDER-INTERFACE.md @@ -9,7 +9,7 @@ Place this file under `docs/` in the repository. ## 1. The scanner contract The boundary search is provider-agnostic. It receives a single **scanner -callback** — a `[scriptblock]` invoked positionally with one argument: +callback** - a `[scriptblock]` invoked positionally with one argument: ```powershell & $scanner # [int64] number of units in the prefix to test @@ -31,16 +31,16 @@ The callback MUST return a single object exposing at least: A `$null` return, or a null/whitespace `Status`, is normalised to `Status = 'Error'` by the search. Exceptions thrown by the callback are caught and converted to -`Status = 'Error'` with the exception message — a provider crash never aborts the +`Status = 'Error'` with the exception message - a provider crash never aborts the search, it fails it cleanly. ## 2. Status tokens `Threat.Search.ps1` classifies every status into exactly one of three buckets: -- **Negative** (`Clean`, `NotDetected`) — the prefix is not detected. -- **Positive** (`Detected`, `Blocked`) — the prefix is detected. -- **Non-definitive** (anything else: `Indeterminate`, `Timeout`, `Error`, ...) — +- **Negative** (`Clean`, `NotDetected`) - the prefix is not detected. +- **Positive** (`Detected`, `Blocked`) - the prefix is detected. +- **Non-definitive** (anything else: `Indeterminate`, `Timeout`, `Error`, ...) - the provider could not give a trustworthy answer. Rules the search enforces: @@ -97,7 +97,7 @@ worker needs isolated resources before any `ForEach-Object -Parallel` is added: - **AMSI RawBytes** is the closest: it already scans an immutable in-memory `byte[]`. Give each worker its own `AmsiSession` and it parallelises cleanly. - **AMSI Text** additionally shares the decoded string and scalar map, both of - which are immutable — again, per-worker sessions are the only requirement. + which are immutable - again, per-worker sessions are the only requirement. - **Defender** needs per-worker independent read streams (or a single preloaded immutable buffer), per-worker copy buffers, and per-worker temp files. @@ -109,6 +109,6 @@ nothing today and removes one landmine from that future work. Every distinct provider invocation (cache miss) appends one record to `ProbeLog` with `Sequence`, `PrefixLength`, `Status`, `ProviderResult`, `SignatureName`, `Cacheable`, `ElapsedMs`, and `TimestampUtc`. Cache hits are not re-logged, so -`ProbeLog.Count` is the true provider cost of a scan — suitable for attaching a +`ProbeLog.Count` is the true provider cost of a scan - suitable for attaching a full boundary-search transcript to an engagement report. The same records stream to `-Verbose` in real time. diff --git a/docs/TELEMETRY-CORRELATION-DESIGN.md b/docs/TELEMETRY-CORRELATION-DESIGN.md index 2c665dc..453cb02 100644 --- a/docs/TELEMETRY-CORRELATION-DESIGN.md +++ b/docs/TELEMETRY-CORRELATION-DESIGN.md @@ -18,7 +18,7 @@ answer, with evidence: - Which telemetry sources were **absent or inaccessible** (a visibility gap is itself a finding)? This turns a boundary scan from "what does the engine detect" into "what does the *defender* -see when this behavior occurs" — the question that matters for detection validation and +see when this behavior occurs" - the question that matters for detection validation and purple-team work. ## Feasibility (validated on a Windows 11 box, 2026-07-21) @@ -28,7 +28,7 @@ Confirmed by recon against a real, non-elevated session with live detections: | Source | Log | State | Notes | |---|---|---|---| | **Microsoft Defender** | `Microsoft-Windows-Windows Defender/Operational` | present, enabled, **readable non-admin** | Primary. 1116 = detection, 1117 = action taken. | -| **PowerShell** | `Microsoft-Windows-PowerShell/Operational` | present, enabled, readable | 4104 script block, 4103 module — the AMSI/script side. | +| **PowerShell** | `Microsoft-Windows-PowerShell/Operational` | present, enabled, readable | 4104 script block, 4103 module - the AMSI/script side. | | Sysmon | `Microsoft-Windows-Sysmon/Operational` | **not installed here** | Optional; report as a gap when absent. | | AMSI | `Microsoft-Windows-AMSI/Operational` | not present | AMSI detections surface via Defender (Source Name = AMSI). | | Security | `Security` | **needs elevation** | Report as inaccessible when non-admin. | @@ -50,11 +50,11 @@ correlation to the scan that produced it. ## Correlation approach 1. **Snapshot** immediately before the scan: capture `TimeCreated` and the highest `RecordId` - for each accessible log (RecordId is monotonic — cheaper and more reliable than time alone + for each accessible log (RecordId is monotonic - cheaper and more reliable than time alone for "events since"). 2. **Run** the provider action (existing `Invoke-OffsetThreatScan` path). 3. **Poll** each accessible log for records with `RecordId > snapshot` (Defender writes events - asynchronously — allow a short bounded poll, e.g. up to ~5 s, before concluding "no + asynchronously - allow a short bounded poll, e.g. up to ~5 s, before concluding "no telemetry"). 4. **Match** candidates to the action by, in order of strength: - `Process Name` == the OffsetInspect host process, @@ -87,7 +87,7 @@ DefenderEvents [ { Id; RecordId; TimeCreated; ...fields } ] ScriptEvents [ { Id=4104; RecordId; TimeCreated } ] CorrelationConfidence High | Medium | Low Findings [ "Alert generated with full context", - "Sysmon not present — process-creation telemetry unavailable" ] + "Sysmon not present - process-creation telemetry unavailable" ] ``` `Findings` renders the principle literally: no alert → *"Absence of an alert is a finding"*; @@ -115,19 +115,19 @@ result; degraded coverage is reported, not fatal. ## Open questions / risks -- Defender event **write latency** — the poll window needs tuning; too short flakes, too long +- Defender event **write latency** - the poll window needs tuning; too short flakes, too long slows the scan. Measure on real detections. -- **Correlation false positives** under concurrent activity — confidence scoring mitigates; +- **Correlation false positives** under concurrent activity - confidence scoring mitigates; consider requiring process match for High. -- Defender **event schema drift** across engine versions — parse defensively by field `Name`, +- Defender **event schema drift** across engine versions - parse defensively by field `Name`, not positional index; fixture tests pin the fields we rely on. -- Non-admin **Security-log** blindness — documented as a known limitation, not worked around. +- Non-admin **Security-log** blindness - documented as a known limitation, not worked around. ## Milestones (~6 weeks) -- **Wk 1–2:** finalize schema; `Get-OIWinEventSnapshot` + query helpers; Defender/PS event +- **Wk 1-2:** finalize schema; `Get-OIWinEventSnapshot` + query helpers; Defender/PS event parsers with XML fixtures + unit tests. -- **Wk 3–4:** correlation engine + confidence scoring + `OffsetInspect.TelemetryCorrelation` +- **Wk 3-4:** correlation engine + confidence scoring + `OffsetInspect.TelemetryCorrelation` object; wire `-CaptureTelemetry` into the scan. - **Wk 5:** `Export-OffsetThreatReport -IncludeTelemetry`; EICAR-based end-to-end tests; gap/negative/timing cases. diff --git a/module/OffsetInspect/OffsetInspect.Format.ps1xml b/module/OffsetInspect/OffsetInspect.Format.ps1xml index 9ab046d..2db99b5 100644 --- a/module/OffsetInspect/OffsetInspect.Format.ps1xml +++ b/module/OffsetInspect/OffsetInspect.Format.ps1xml @@ -53,5 +53,31 @@ + + OffsetInspect.SignatureInfo.Table + + OffsetInspect.SignatureInfo + + + + 30 + 7 + 14 + 12 + + + + + + File + IsSigned + Status + SignatureType + SignerName + + + + + diff --git a/module/OffsetInspect/OffsetInspect.psd1 b/module/OffsetInspect/OffsetInspect.psd1 index 9cd74cf..59d342b 100644 --- a/module/OffsetInspect/OffsetInspect.psd1 +++ b/module/OffsetInspect/OffsetInspect.psd1 @@ -1,6 +1,6 @@ @{ RootModule = 'OffsetInspect.psm1' - ModuleVersion = '3.2.0' + ModuleVersion = '3.3.0' GUID = '2d9f6f83-2c4f-4a6e-8a53-1cf9a5fbc2f6' Author = 'Jared Perry (Velkris)' CompanyName = 'DreadHost Research' @@ -31,6 +31,7 @@ 'Private/Threat.Mutation.ps1', 'Private/Threat.Region.ps1', 'Private/Threat.Search.ps1', + 'Private/Threat.Signature.ps1', 'Private/Threat.Telemetry.ps1', 'Private/Threat.Text.ps1', 'Private/Threat.Trigger.ps1', @@ -49,6 +50,7 @@ 'Public/Get-OffsetDrift.ps1', 'Public/Get-OffsetEntropy.ps1', 'Public/Get-OffsetIOC.ps1', + 'Public/Get-OffsetSignature.ps1', 'Public/Get-OffsetString.ps1', 'Public/Get-OffsetPEInfo.ps1' ) @@ -68,6 +70,7 @@ 'Get-OffsetDrift', 'Get-OffsetEntropy', 'Get-OffsetIOC', + 'Get-OffsetSignature', 'Get-OffsetString', 'Get-OffsetPEInfo' ) @@ -87,11 +90,15 @@ 'Hex', 'AMSI', 'MicrosoftDefender', + 'Authenticode', 'OffsetAnalysis' ) LicenseUri = 'https://github.com/warpedatom/OffsetInspect/blob/main/LICENSE' ProjectUri = 'https://github.com/warpedatom/OffsetInspect' ReleaseNotes = @' +OffsetInspect 3.3.0 +- Adds Get-OffsetSignature: reports a file's Authenticode signature using the platform's real trust validation (Get-AuthenticodeSignature / WinVerifyTrust) - not just "is a cert present" but is the signature Valid and trusted against this machine's trust store, who signed it (subject/issuer/thumbprint/validity), whether it is embedded- or catalog-signed, and whether it is a Windows OS binary. Returns the OffsetInspect.SignatureInfo object (IsSigned, Status, SignatureType, IsCatalogSigned, IsOSBinary, SignerName/Subject/Issuer/Thumbprint/Serial, SignerNotBefore/NotAfter, SignerExpired, IsTimestamped, TimestamperName). A provenance signal that complements imphash and the Rich-header fingerprint: imports vs build toolchain vs signer. An unsigned binary wearing a system-DLL name, or a present-but-not-Valid signature, is a triage finding. Windows-only (Authenticode trust validation needs the Windows trust store); the object-shaping is a pure, unit-tested helper so it runs cross-platform in CI. Pairs with the OffsetScan 0.4.0 Rich-header release. + OffsetInspect 3.2.0 - Adds telemetry correlation: Invoke-OffsetThreatScan -CaptureTelemetry snapshots the Windows telemetry high-water marks before a scan and, after it, reports whether the action generated a defender alert, with what context (threat name, severity, detection source, process, user), and which telemetry sources were blind. Encodes the "assume visibility, then validate it" principle: absence of an alert, an alert without context, or a missing source are each surfaced as findings. Adds the OffsetInspect.TelemetryCorrelation object (Telemetry property on ThreatScanResult). Primary source is the Microsoft Defender Operational log (1116/1117), read non-admin; correlation is by RecordId (timezone-proof) with confidence scored High only on matching detection source AND scanning process. Sysmon/Security are reported as visibility gaps when absent or inaccessible. Windows-only; off unless -CaptureTelemetry is passed. diff --git a/module/OffsetInspect/OffsetInspect.psm1 b/module/OffsetInspect/OffsetInspect.psm1 index 3f9f1fb..f6884ce 100644 --- a/module/OffsetInspect/OffsetInspect.psm1 +++ b/module/OffsetInspect/OffsetInspect.psm1 @@ -22,6 +22,7 @@ Export-ModuleMember -Function @( 'Get-OffsetDrift', 'Get-OffsetEntropy', 'Get-OffsetIOC', + 'Get-OffsetSignature', 'Get-OffsetString', 'Get-OffsetPEInfo' ) diff --git a/module/OffsetInspect/Private/Threat.Signature.ps1 b/module/OffsetInspect/Private/Threat.Signature.ps1 new file mode 100644 index 0000000..f5ebd89 --- /dev/null +++ b/module/OffsetInspect/Private/Threat.Signature.ps1 @@ -0,0 +1,100 @@ +# Authenticode signature verification (Windows-native). Get-OffsetSignature wraps the +# platform's real trust validation (Get-AuthenticodeSignature / WinVerifyTrust) and shapes an +# OffsetInspect.SignatureInfo. The shaping (ConvertTo-OISignatureInfo) is a pure function +# unit-tested against fixture objects, so it runs cross-platform in CI without a live signature; +# only the live provider call in the public cmdlet is Windows-only. This answers a provenance +# question static parsing cannot: not just "is there a cert" but "is the signature valid and +# trusted on this machine, and who signed it" - a complement to imphash and the Rich-header +# RichHash (imports vs build toolchain vs signer). + +function Get-OIMemberValue { + # StrictMode-safe property read: returns the member's value, or $null when the object lacks + # it. Works on real .NET objects (Signature, X509Certificate2) and pscustomobject fixtures, + # and tolerates PowerShell versions where SignatureType/IsOSBinary may be absent. + [CmdletBinding()] + param( + [AllowNull()]$InputObject, + [Parameter(Mandatory = $true)][string]$Name + ) + if ($null -eq $InputObject) { return $null } + $member = $InputObject.PSObject.Properties[$Name] + if ($member) { return $member.Value } + return $null +} + +function Get-OICommonName { + # Pull the CN (common name) out of an X.500 subject/issuer for a readable label, falling + # back to the full distinguished name. + [CmdletBinding()] + param([AllowNull()][string]$DistinguishedName) + if ([string]::IsNullOrWhiteSpace($DistinguishedName)) { return $null } + foreach ($part in $DistinguishedName -split ',') { + $trimmed = $part.Trim() + if ($trimmed -match '^CN=(.+)$') { return $Matches[1].Trim() } + } + return $DistinguishedName.Trim() +} + +function ConvertTo-OISignatureInfo { + <# + Pure: shape a Get-AuthenticodeSignature result (or any compatible object) into an + OffsetInspect.SignatureInfo. Every optional member is read defensively so it is safe + under Set-StrictMode and across PowerShell versions. Unit-tested with fixtures - no live + signature required. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + $Signature, + + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$File, + + [int64]$FileSize = -1 + ) + + $status = [string](Get-OIMemberValue -InputObject $Signature -Name 'Status') + if ([string]::IsNullOrEmpty($status)) { $status = 'UnknownError' } + + $signerCert = Get-OIMemberValue -InputObject $Signature -Name 'SignerCertificate' + $tsaCert = Get-OIMemberValue -InputObject $Signature -Name 'TimeStamperCertificate' + + # A signer certificate is present for both embedded and catalog signatures (even when the + # status is not Valid, e.g. NotTrusted/HashMismatch), and absent when there is no signature + # (NotSigned) or the format can't be checked - so it is the reliable "is it signed" signal. + $isSigned = ($null -ne $signerCert) + + $signerSubject = Get-OIMemberValue -InputObject $signerCert -Name 'Subject' + $signerNotAfter = Get-OIMemberValue -InputObject $signerCert -Name 'NotAfter' + $signerExpired = $null + if ($signerNotAfter -is [datetime]) { $signerExpired = ((Get-Date) -gt $signerNotAfter) } + + $signatureType = Get-OIMemberValue -InputObject $Signature -Name 'SignatureType' + $signatureTypeString = if ($null -ne $signatureType) { [string]$signatureType } else { $null } + $isOsBinary = Get-OIMemberValue -InputObject $Signature -Name 'IsOSBinary' + + $result = [pscustomobject]@{ + File = $File + FileSize = $FileSize + IsSigned = $isSigned + Status = $status + StatusMessage = Get-OIMemberValue -InputObject $Signature -Name 'StatusMessage' + SignatureType = $signatureTypeString + IsCatalogSigned = ($signatureTypeString -eq 'Catalog') + IsOSBinary = if ($null -ne $isOsBinary) { [bool]$isOsBinary } else { $null } + SignerName = Get-OICommonName -DistinguishedName $signerSubject + SignerSubject = $signerSubject + SignerIssuer = Get-OIMemberValue -InputObject $signerCert -Name 'Issuer' + SignerThumbprint = Get-OIMemberValue -InputObject $signerCert -Name 'Thumbprint' + SignerSerial = Get-OIMemberValue -InputObject $signerCert -Name 'SerialNumber' + SignerNotBefore = Get-OIMemberValue -InputObject $signerCert -Name 'NotBefore' + SignerNotAfter = $signerNotAfter + SignerExpired = $signerExpired + IsTimestamped = [bool]$tsaCert + TimestamperName = Get-OICommonName -DistinguishedName (Get-OIMemberValue -InputObject $tsaCert -Name 'Subject') + Warnings = @() + } + $result.PSObject.TypeNames.Insert(0, 'OffsetInspect.SignatureInfo') + return $result +} diff --git a/module/OffsetInspect/Public/Get-OffsetSignature.ps1 b/module/OffsetInspect/Public/Get-OffsetSignature.ps1 new file mode 100644 index 0000000..cb0076d --- /dev/null +++ b/module/OffsetInspect/Public/Get-OffsetSignature.ps1 @@ -0,0 +1,53 @@ +function Get-OffsetSignature { + <# + .SYNOPSIS + Reports a file's Authenticode signature and whether it is valid and trusted (Windows-only). + + .DESCRIPTION + Wraps the platform's real Authenticode verification (Get-AuthenticodeSignature / + WinVerifyTrust) to answer a provenance question static parsing cannot: is this binary + signed, is the signature valid against this machine's trust store, who signed it, is it + embedded- or catalog-signed, and is it a Windows OS binary? Returns an + OffsetInspect.SignatureInfo. + + This complements the build-toolchain fingerprints (imphash, and OffsetScan's Rich-header + RichHash): imports vs toolchain vs signer. An unsigned binary wearing a system-DLL name, + or a signature that is present but not Valid/trusted, is a triage signal. + + Windows-only: Authenticode trust validation needs the Windows trust store, so on other + platforms this throws (mirroring the AMSI/Defender threat providers). The signature is + checked against the trust state of the machine it runs on; run it in your analysis + environment, not the target's. + + .PARAMETER FilePath + The file to verify. + + .EXAMPLE + Get-OffsetSignature .\sample.exe + + .EXAMPLE + Get-ChildItem *.exe | + ForEach-Object { Get-OffsetSignature $_.FullName } | + Where-Object { -not $_.IsSigned -or $_.Status -ne 'Valid' } + + Surface every unsigned or not-validly-signed executable in a folder. + #> + [CmdletBinding()] + [OutputType('OffsetInspect.SignatureInfo')] + param( + [Parameter(Mandatory = $true, Position = 0)] + [ValidateNotNullOrEmpty()] + [string]$FilePath + ) + + if (-not (Test-OIIsWindows)) { + throw 'Get-OffsetSignature requires Windows: Authenticode trust validation uses the Windows trust store (Get-AuthenticodeSignature).' + } + + $resolvedPath = (Resolve-Path -LiteralPath $FilePath -ErrorAction Stop).Path + $item = Get-Item -LiteralPath $resolvedPath -ErrorAction Stop + if ($item.PSIsContainer) { throw "Path is a directory, not a file: $resolvedPath" } + + $signature = Get-AuthenticodeSignature -LiteralPath $resolvedPath -ErrorAction Stop + return ConvertTo-OISignatureInfo -Signature $signature -File $resolvedPath -FileSize $item.Length +} diff --git a/tests/OffsetInspect.Tests.ps1 b/tests/OffsetInspect.Tests.ps1 index b7eec45..77037c2 100644 --- a/tests/OffsetInspect.Tests.ps1 +++ b/tests/OffsetInspect.Tests.ps1 @@ -36,15 +36,15 @@ AfterAll { Remove-Module OffsetInspect -Force -ErrorAction SilentlyContinue } Describe 'OffsetInspect module package' { - It 'has a valid 3.2.0 manifest' { + It 'has a valid 3.3.0 manifest' { $manifest = Test-ModuleManifest -Path $ManifestPath -ErrorAction Stop - $manifest.Version.ToString() | Should -Be '3.2.0' + $manifest.Version.ToString() | Should -Be '3.3.0' $manifest.RootModule | Should -Be 'OffsetInspect.psm1' } It 'exports only the supported public commands' { $commands = @(Get-Command -Module OffsetInspect | Select-Object -ExpandProperty Name | Sort-Object) - ($commands -join ',') | Should -Be 'Add-OffsetDriftEntry,Compare-OffsetThreatResult,Export-OffsetThreatReport,Get-OffsetDetectionTrigger,Get-OffsetDrift,Get-OffsetEntropy,Get-OffsetIOC,Get-OffsetPEInfo,Get-OffsetString,Invoke-OffsetClamScan,Invoke-OffsetInspect,Invoke-OffsetMutationTest,Invoke-OffsetThreatScan,Invoke-OffsetThreatScanBatch,Invoke-OffsetThreatScanRegion,Invoke-OffsetYaraScan' + ($commands -join ',') | Should -Be 'Add-OffsetDriftEntry,Compare-OffsetThreatResult,Export-OffsetThreatReport,Get-OffsetDetectionTrigger,Get-OffsetDrift,Get-OffsetEntropy,Get-OffsetIOC,Get-OffsetPEInfo,Get-OffsetSignature,Get-OffsetString,Invoke-OffsetClamScan,Invoke-OffsetInspect,Invoke-OffsetMutationTest,Invoke-OffsetThreatScan,Invoke-OffsetThreatScanBatch,Invoke-OffsetThreatScanRegion,Invoke-OffsetYaraScan' } It 'imports from an isolated Gallery-style folder' { @@ -2005,3 +2005,97 @@ Describe 'ClamAV output parsing' { $result.Error | Should -Match 'database' } } + +Describe 'Authenticode signature shaping' { + It 'shapes a valid catalog-signed result with the signer details' { + $info = InModuleScope OffsetInspect { + $sig = [pscustomobject]@{ + Status = 'Valid' + StatusMessage = 'Signature verified.' + SignatureType = 'Catalog' + IsOSBinary = $true + SignerCertificate = [pscustomobject]@{ + Subject = 'CN=Microsoft Windows, O=Microsoft Corporation, L=Redmond, S=Washington, C=US' + Issuer = 'CN=Microsoft Windows Production PCA 2011, O=Microsoft Corporation' + Thumbprint = 'DC91E564D5BC1E3A8E02D6A8508682ABEA8A2443' + SerialNumber = '33000002ABCD' + NotBefore = (Get-Date).AddYears(-1) + NotAfter = (Get-Date).AddYears(1) + } + TimeStamperCertificate = [pscustomobject]@{ Subject = 'CN=Microsoft Time-Stamp Service, O=Microsoft Corporation' } + } + ConvertTo-OISignatureInfo -Signature $sig -File 'kernel32.dll' -FileSize 836232 + } + $info.PSObject.TypeNames | Should -Contain 'OffsetInspect.SignatureInfo' + $info.IsSigned | Should -BeTrue + $info.Status | Should -Be 'Valid' + $info.SignatureType | Should -Be 'Catalog' + $info.IsCatalogSigned | Should -BeTrue + $info.IsOSBinary | Should -BeTrue + $info.SignerName | Should -Be 'Microsoft Windows' + $info.SignerThumbprint | Should -Be 'DC91E564D5BC1E3A8E02D6A8508682ABEA8A2443' + $info.SignerExpired | Should -BeFalse + $info.IsTimestamped | Should -BeTrue + $info.TimestamperName | Should -Be 'Microsoft Time-Stamp Service' + } + + It 'shapes an unsigned result with null signer fields' { + $info = InModuleScope OffsetInspect { + $sig = [pscustomobject]@{ + Status = 'NotSigned' + StatusMessage = 'The file is not digitally signed.' + SignatureType = 'None' + IsOSBinary = $false + SignerCertificate = $null + TimeStamperCertificate = $null + } + ConvertTo-OISignatureInfo -Signature $sig -File 'sample.exe' -FileSize 4096 + } + $info.IsSigned | Should -BeFalse + $info.Status | Should -Be 'NotSigned' + $info.IsCatalogSigned | Should -BeFalse + $info.SignerName | Should -BeNullOrEmpty + $info.SignerSubject | Should -BeNullOrEmpty + $info.IsTimestamped | Should -BeFalse + $info.SignerExpired | Should -BeNullOrEmpty + } + + It 'treats a present-but-invalid signature (HashMismatch) as signed' { + $info = InModuleScope OffsetInspect { + $sig = [pscustomobject]@{ + Status = 'HashMismatch' + StatusMessage = 'The contents of the file were altered.' + SignerCertificate = [pscustomobject]@{ Subject = 'CN=Some Vendor'; NotAfter = (Get-Date).AddYears(1) } + TimeStamperCertificate = $null + } + ConvertTo-OISignatureInfo -Signature $sig -File 'tampered.exe' + } + # A signer cert is present even though the signature does not validate. + $info.IsSigned | Should -BeTrue + $info.Status | Should -Be 'HashMismatch' + # SignatureType/IsOSBinary are absent on this object: must be null, not a StrictMode throw. + $info.SignatureType | Should -BeNullOrEmpty + $info.IsOSBinary | Should -BeNullOrEmpty + $info.IsCatalogSigned | Should -BeFalse + } + + It 'flags an expired signer certificate' { + $info = InModuleScope OffsetInspect { + $sig = [pscustomobject]@{ + Status = 'Valid' + SignerCertificate = [pscustomobject]@{ Subject = 'CN=Old Vendor'; NotAfter = (Get-Date).AddDays(-1) } + } + ConvertTo-OISignatureInfo -Signature $sig -File 'old.exe' + } + $info.SignerExpired | Should -BeTrue + } + + It 'reports a real signed Windows system binary end-to-end' -Skip:($env:OS -ne 'Windows_NT') { + $target = Join-Path $env:SystemRoot 'System32\kernel32.dll' + $info = Get-OffsetSignature -FilePath $target + $info.PSObject.TypeNames | Should -Contain 'OffsetInspect.SignatureInfo' + $info.IsSigned | Should -BeTrue + $info.Status | Should -Be 'Valid' + $info.SignerName | Should -Not -BeNullOrEmpty + } +}